Files
qipai/backend/tests/member-profile-service.test.mjs
T
2026-06-25 12:07:09 +08:00

480 lines
15 KiB
JavaScript

import assert from 'node:assert/strict';
import {
MemberProfileError,
MemberProfileService
} from '../dist/wallets/member-profile-service.js';
const customers = [
{
id: '21',
tenantId: '7',
userType: 'CUSTOMER',
status: 'ACTIVE',
nickname: 'Alice',
phone: '13800138000',
createdAt: new Date('2026-06-01T00:00:00.000Z'),
lastLoginAt: new Date('2026-06-20T00:00:00.000Z'),
deleted: false
},
{
id: '22',
tenantId: '7',
userType: 'CUSTOMER',
status: 'ACTIVE',
nickname: 'Bob',
phone: '13900139000',
createdAt: new Date('2026-06-02T00:00:00.000Z'),
lastLoginAt: null,
deleted: false
},
{
id: '23',
tenantId: '8',
userType: 'CUSTOMER',
status: 'ACTIVE',
nickname: 'OtherTenant',
phone: '13700137000',
createdAt: new Date('2026-06-03T00:00:00.000Z'),
lastLoginAt: null,
deleted: false
}
];
const wallets = [
{ tenantId: '7', userId: '21', storeId: '11', status: 'ACTIVE', cash: 10_000, gift: 2_000 },
{ tenantId: '7', userId: '21', storeId: null, status: 'ACTIVE', cash: 5_000, gift: 500 },
{ tenantId: '7', userId: '22', storeId: '12', status: 'ACTIVE', cash: 1_000, gift: 0 }
];
const coupons = [
{
id: '401',
tenantId: '7',
userId: '21',
templateId: '301',
name: '30元抵扣券',
status: 'AVAILABLE',
couponType: 'FULL_REDUCTION',
discountAmountCents: 3000,
timeMinutes: 0,
minOrderAmountCents: 10000,
remainingUses: 1,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-07-01T00:00:00.000Z'),
storeId: '11',
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: null,
usedAt: null
},
{
id: '402',
tenantId: '7',
userId: '21',
templateId: '302',
name: '60分钟券',
status: 'FROZEN',
couponType: 'TIME',
discountAmountCents: 0,
timeMinutes: 60,
minOrderAmountCents: 0,
remainingUses: 1,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-07-02T00:00:00.000Z'),
storeId: null,
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: '301',
usedAt: null
},
{
id: '403',
tenantId: '7',
userId: '22',
templateId: '303',
name: '其他用户券',
status: 'USED',
couponType: 'FULL_REDUCTION',
discountAmountCents: 500,
timeMinutes: 0,
minOrderAmountCents: 0,
remainingUses: 0,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-07-01T00:00:00.000Z'),
storeId: null,
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: null,
usedAt: new Date('2026-06-20T00:00:00.000Z')
}
];
const packages = [
{
id: '501',
tenantId: '7',
userId: '21',
planId: '601',
name: '三小时畅玩包',
status: 'ACTIVE',
priceCents: 9000,
minutesTotal: 180,
amountCentsTotal: 3000,
remainingMinutes: 120,
remainingAmountCents: 3_000,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-08-01T00:00:00.000Z'),
storeId: '11',
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: null
},
{
id: '502',
tenantId: '7',
userId: '21',
planId: '602',
name: '锁定中的套餐',
status: 'FROZEN',
priceCents: 5000,
minutesTotal: 60,
amountCentsTotal: 2000,
remainingMinutes: 60,
remainingAmountCents: 2_000,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-08-02T00:00:00.000Z'),
storeId: null,
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: '301'
}
];
const recharges = [
{
tenantId: '7',
userId: '21',
status: 'CREDITED',
payAmountCents: 10_000,
giftAmountCents: 2_000,
creditedAt: new Date('2026-06-21T00:00:00.000Z')
},
{
tenantId: '7',
userId: '21',
status: 'PENDING_PAYMENT',
payAmountCents: 5_000,
giftAmountCents: 500,
creditedAt: null
}
];
const ledger = [
{
id: '1002',
tenantId: '7',
userId: '21',
storeId: '11',
businessType: 'ORDER',
businessId: 'order-1',
entryType: 'CONSUME',
cashDeltaCents: -200,
giftDeltaCents: -300,
cashBalanceAfterCents: 14_800,
giftBalanceAfterCents: 2_200,
createdAt: new Date('2026-06-22T00:00:00.000Z')
},
{
id: '1001',
tenantId: '7',
userId: '21',
storeId: '11',
businessType: 'RECHARGE',
businessId: 'recharge-1',
entryType: 'RECHARGE',
cashDeltaCents: 10_000,
giftDeltaCents: 2_000,
cashBalanceAfterCents: 15_000,
giftBalanceAfterCents: 2_500,
createdAt: new Date('2026-06-21T00:00:00.000Z')
}
];
const orders = [
{
id: '301',
tenantId: '7',
userId: '21',
status: 'COMPLETED',
paidAmountCents: 12_000,
createdAt: new Date('2026-06-23T00:00:00.000Z')
},
{
id: '302',
tenantId: '7',
userId: '21',
status: 'CANCELLED',
paidAmountCents: 0,
createdAt: new Date('2026-06-24T00:00:00.000Z')
}
];
function createService() {
const pool = {
async execute(sql, params) {
if (sql.includes('COUNT(DISTINCT u.id)')) {
return [[{ total: queryMembers(sql, params).length }], []];
}
if (sql.includes('FROM qipai_users u') && sql.includes('ORDER BY u.id DESC')) {
return [queryMembers(sql, params).sort((a, b) => Number(b.id) - Number(a.id)), []];
}
if (sql.includes('FROM qipai_users u') && sql.includes('LIMIT 1')) {
const found = queryMembers(sql, params).find((user) => user.id === String(params[1]));
return [found ? [found] : [], []];
}
if (sql.includes('FROM qipai_wallet_accounts')) {
const tenantId = String(params[0]);
const userId = String(params[1]);
const rows = wallets.filter((item) =>
item.tenantId === tenantId && item.userId === userId && item.status === 'ACTIVE'
);
return [[{
accountCount: rows.length,
cashBalanceCents: rows.reduce((sum, item) => sum + item.cash, 0),
giftBalanceCents: rows.reduce((sum, item) => sum + item.gift, 0)
}], []];
}
if (sql.includes('FROM qipai_coupon_grants c')) {
const tenantId = String(params[8]);
const userId = String(params[9]);
const memberCoupons = coupons.filter((item) =>
item.tenantId === tenantId && item.userId === userId
);
const memberPackages = packages.filter((item) =>
item.tenantId === tenantId && item.userId === userId
);
return [[{
availableCoupons: memberCoupons.filter((item) => item.status === 'AVAILABLE').length,
frozenCoupons: memberCoupons.filter((item) => item.status === 'FROZEN').length,
activePackages: memberPackages.filter((item) => item.status === 'ACTIVE').length,
frozenPackages: memberPackages.filter((item) => item.status === 'FROZEN').length,
packageMinutes: memberPackages
.filter((item) => ['ACTIVE', 'FROZEN'].includes(item.status))
.reduce((sum, item) => sum + item.remainingMinutes, 0),
packageAmountCents: memberPackages
.filter((item) => ['ACTIVE', 'FROZEN'].includes(item.status))
.reduce((sum, item) => sum + item.remainingAmountCents, 0)
}], []];
}
if (sql.includes('FROM qipai_coupon_grants g')) {
const rows = coupons
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
.slice(0, Number(params[2]));
return [rows.map((item) => ({
id: item.id,
templateId: item.templateId,
name: item.name,
status: item.status,
couponType: item.couponType,
discountAmountCents: item.discountAmountCents,
timeMinutes: item.timeMinutes,
minOrderAmountCents: item.minOrderAmountCents,
remainingUses: item.remainingUses,
validFrom: item.validFrom,
validTo: item.validTo,
storeId: item.storeId,
roomCategoryId: item.roomCategoryId,
roomId: item.roomId,
holidayOnly: item.holidayOnly,
frozenOrderId: item.frozenOrderId,
usedAt: item.usedAt
})), []];
}
if (sql.includes('FROM qipai_package_holdings h')) {
const rows = packages
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
.slice(0, Number(params[2]));
return [rows.map((item) => ({
id: item.id,
planId: item.planId,
name: item.name,
status: item.status,
priceCents: item.priceCents,
minutesTotal: item.minutesTotal,
amountCentsTotal: item.amountCentsTotal,
remainingMinutes: item.remainingMinutes,
remainingAmountCents: item.remainingAmountCents,
validFrom: item.validFrom,
validTo: item.validTo,
storeId: item.storeId,
roomCategoryId: item.roomCategoryId,
roomId: item.roomId,
holidayOnly: item.holidayOnly,
frozenOrderId: item.frozenOrderId
})), []];
}
if (sql.includes('FROM qipai_recharge_orders')) {
const tenantId = String(params[0]);
const userId = String(params[1]);
const rows = recharges.filter((item) => item.tenantId === tenantId && item.userId === userId);
const credited = rows.filter((item) => item.status === 'CREDITED');
return [[{
rechargeOrderCount: rows.length,
creditedRechargeCount: credited.length,
creditedRechargeCents: credited.reduce((sum, item) => sum + item.payAmountCents, 0),
giftedRechargeCents: credited.reduce((sum, item) => sum + item.giftAmountCents, 0),
lastRechargeAt: credited[0]?.creditedAt ?? null
}], []];
}
if (sql.includes('FROM qipai_orders o')) {
const userId = String(params[0]);
const tenantId = String(params[1]);
const rows = orders.filter((item) => item.tenantId === tenantId && item.userId === userId);
const paid = rows.filter((item) => ['PAID', 'IN_USE', 'COMPLETED'].includes(item.status));
return [[{
orderCount: rows.length,
paidOrderCount: paid.length,
paidAmountCents: paid.reduce((sum, item) => sum + item.paidAmountCents, 0),
lastOrderAt: rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]?.createdAt ?? null
}], []];
}
if (sql.includes('FROM qipai_wallet_ledger_entries')) {
const rows = ledger
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
.sort((a, b) => Number(b.id) - Number(a.id))
.slice(0, Number(params[2]));
return [rows.map((item) => ({
id: item.id,
storeId: item.storeId,
businessType: item.businessType,
businessId: item.businessId,
entryType: item.entryType,
cashDeltaCents: item.cashDeltaCents,
giftDeltaCents: item.giftDeltaCents,
cashBalanceAfterCents: item.cashBalanceAfterCents,
giftBalanceAfterCents: item.giftBalanceAfterCents,
createdAt: item.createdAt
})), []];
}
throw new Error(`Unexpected SQL: ${sql}`);
}
};
return new MemberProfileService(pool);
}
function queryMembers(sql, params) {
if (sql.includes('1 = 0')) return [];
const tenantId = String(params[0]);
const statusFilter = sql.includes('u.status = ?') ? params[1] : null;
const hasStoreScope = sql.includes('qipai_wallet_accounts wa');
const storeParams = hasStoreScope
? params.slice(statusFilter ? 2 : 1).filter((value) => /^\d+$/.test(String(value)))
: [];
return customers.filter((user) => {
if (user.tenantId !== tenantId || user.userType !== 'CUSTOMER' || user.deleted) return false;
if (statusFilter && user.status !== statusFilter) return false;
if (hasStoreScope) {
return wallets.some((wallet) =>
wallet.tenantId === tenantId
&& wallet.userId === user.id
&& storeParams.includes(wallet.storeId)
);
}
return true;
}).map((user) => ({
id: user.id,
status: user.status,
nickname: user.nickname,
phone: user.phone,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt
}));
}
const tenantAdmin = {
tenantId: '7',
userId: '1',
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] }
};
const storeReader = {
tenantId: '7',
userId: '2',
access: { roles: ['STORE_ADMIN'], capabilities: ['user.read'], storeIds: ['11'] }
};
const forbiddenReader = {
tenantId: '7',
userId: '3',
access: { roles: ['STAFF'], capabilities: ['user.read'], storeIds: [] }
};
{
const service = createService();
const list = await service.listMembers({
actor: tenantAdmin,
page: 1,
pageSize: 20
});
assert.equal(list.total, 2);
assert.equal(list.items[0].memberId, '22');
assert.equal(list.items[1].wallet.totalBalanceCents, 17_500);
assert.equal(list.items[1].benefits.availableCoupons, 1);
assert.equal(list.items[1].benefits.frozenPackages, 1);
assert.equal(list.items[1].recharge.creditedRechargeCents, 10_000);
assert.equal(list.items[1].orders.orderCount, 2);
assert.equal(list.items[1].orders.paidAmountCents, 12_000);
assert.equal(list.items[1].maskedPhone, '138****8000');
}
{
const service = createService();
const member = await service.getMember({ actor: tenantAdmin, memberId: '21' });
assert.equal(member.recentLedger.length, 2);
assert.equal(member.recentLedger[0].ledgerId, '1002');
assert.equal(member.wallet.cashBalanceCents, 15_000);
assert.equal(member.benefits.packageMinutes, 180);
}
{
const service = createService();
const benefits = await service.getMyBenefits({ tenantId: '7', userId: '21' });
assert.equal(benefits.coupons.length, 2);
assert.equal(benefits.coupons[0].couponGrantId, '401');
assert.equal(benefits.coupons[0].discountAmountCents, 3000);
assert.equal(benefits.coupons[1].frozenOrderId, '301');
assert.equal(benefits.packages.length, 2);
assert.equal(benefits.packages[0].packageHoldingId, '501');
assert.equal(benefits.packages[0].remainingAmountCents, 3000);
}
{
const service = createService();
const scoped = await service.listMembers({
actor: storeReader,
page: 1,
pageSize: 20
});
assert.equal(scoped.total, 1);
assert.equal(scoped.items[0].memberId, '21');
await assert.rejects(
() => service.getMember({ actor: storeReader, memberId: '22' }),
(error) => error instanceof MemberProfileError && error.code === 'MEMBER_NOT_FOUND'
);
}
{
const service = createService();
const scoped = await service.listMembers({
actor: forbiddenReader,
page: 1,
pageSize: 20
});
assert.equal(scoped.total, 0);
}
console.log('PASS: M07-D member profiles aggregate wallets, benefits, recharge and scoped access.');