文件
OmniCard/backend/test/modules/team/platform-subscription.test.ts
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

498 行
17 KiB
TypeScript

import { PlatformSubscriptionService } from '../../../src/modules/team/service/platform-subscription';
import { AdminPlatformSubscriptionController } from '../../../src/modules/team/controller/admin/platform';
import { TeamSubscriptionEntity } from '../../../src/modules/team/entity/subscription';
import { TeamInvoiceEntity } from '../../../src/modules/team/entity/invoice';
import {
RenewalOrderItem,
SubscriberItem,
} from '../../../src/comm/types/omni';
/** 纯内存单测用样例会员(非生产种子) */
function memorySubscribers(): SubscriberItem[] {
return [
{
id: 1,
tenantId: 1,
tenantName: 'Acme Memory Tenant',
adminName: 'Alice Admin',
adminEmail: 'alice@acme.test',
adminPhone: '+1 555 0100',
planType: 'enterprise',
status: 'active',
cardsUsed: 2,
cardsLimit: 100,
seatsUsed: 1,
seatsLimit: 50,
totalPaidAmount: 1000,
currency: 'USD',
startDate: '2025-09-01',
expireDate: '2026-09-01',
createdAt: '2025-08-28 10:20:00',
},
{
id: 2,
tenantId: 2,
tenantName: 'Beta Memory Co',
adminName: 'Bob Manager',
adminEmail: 'bob@beta.test',
planType: 'pro',
status: 'active',
cardsUsed: 3,
cardsLimit: 10,
seatsUsed: 2,
seatsLimit: 5,
totalPaidAmount: 588,
currency: 'USD',
startDate: '2026-01-15',
expireDate: '2027-01-15',
createdAt: '2026-01-10 14:35:12',
},
{
id: 3,
tenantId: 3,
tenantName: 'Gamma Free Org',
adminName: 'Carol Free',
adminEmail: 'carol@gamma.test',
planType: 'free',
status: 'active',
cardsUsed: 1,
cardsLimit: 1,
seatsUsed: 1,
seatsLimit: 1,
totalPaidAmount: 0,
currency: 'USD',
startDate: '2026-08-01',
expireDate: '2099-12-31',
createdAt: '2026-08-01 10:00:00',
},
];
}
function memoryRenewals(): RenewalOrderItem[] {
return [
{
id: 1,
orderNo: 'ORD-MEM-0001',
tenantId: 1,
tenantName: 'Acme Memory Tenant',
planType: 'enterprise',
billingCycle: 'yearly',
amount: 2990,
currency: 'USD',
channel: 'stripe',
status: 'paid',
paidAt: '2026-09-21 00:15:20',
createdAt: '2026-09-21 00:15:10',
},
{
id: 2,
orderNo: 'ORD-MEM-0002',
tenantId: 2,
tenantName: 'Beta Memory Co',
planType: 'pro',
billingCycle: 'yearly',
amount: 490,
currency: 'USD',
channel: 'manual_admin',
status: 'paid',
paidAt: '2026-09-20 18:42:10',
createdAt: '2026-09-20 18:42:00',
},
];
}
function seedMemory(service: PlatformSubscriptionService) {
(service as unknown as { subscribersStore: SubscriberItem[] }).subscribersStore =
memorySubscribers();
(service as unknown as { renewalsStore: RenewalOrderItem[] }).renewalsStore =
memoryRenewals();
}
function createMockSubRepo(rows: Partial<TeamSubscriptionEntity>[] = []) {
const list = rows.map((r, i) => ({
id: r.id ?? i + 1,
tenantId: r.tenantId ?? i + 1,
planType: r.planType ?? 'free',
billingCycle: r.billingCycle ?? 'monthly',
status: r.status ?? 'active',
currentPeriodEnd: r.currentPeriodEnd ?? null,
cancelAtPeriodEnd: r.cancelAtPeriodEnd ?? false,
createTime: r.createTime ?? '2026-01-01 00:00:00',
updateTime: r.updateTime ?? '2026-01-01 00:00:00',
...r,
})) as TeamSubscriptionEntity[];
return {
find: jest.fn(async () => [...list]),
findOne: jest.fn(async (opts?: { where?: { tenantId?: number } }) => {
const tid = opts?.where?.tenantId;
if (tid === undefined) return null;
return list.find(s => Number(s.tenantId) === Number(tid)) || null;
}),
save: jest.fn(async (entity: TeamSubscriptionEntity) => {
const idx = list.findIndex(s => Number(s.tenantId) === Number(entity.tenantId));
if (idx >= 0) {
Object.assign(list[idx], entity);
return list[idx];
}
list.push(entity);
return entity;
}),
};
}
function createMockInvoiceRepo(rows: Partial<TeamInvoiceEntity>[] = []) {
const list = rows.map((r, i) => ({
id: r.id ?? i + 1,
tenantId: r.tenantId ?? i + 1,
invoiceNo: r.invoiceNo ?? `INV-${i + 1}`,
orderId: r.orderId ?? null,
currency: r.currency ?? 'USD',
currencySymbol: r.currencySymbol ?? '$',
subtotal: r.subtotal ?? 0,
taxRate: r.taxRate ?? 0,
taxAmount: r.taxAmount ?? 0,
totalAmount: r.totalAmount ?? 0,
title: r.title ?? 'Subscription',
status: r.status ?? 'paid',
paidAt: r.paidAt ?? null,
createTime: r.createTime ?? '2026-01-01 00:00:00',
updateTime: r.updateTime ?? '2026-01-01 00:00:00',
...r,
})) as TeamInvoiceEntity[];
return {
find: jest.fn(async () => [...list]),
findOne: jest.fn(async () => null),
save: jest.fn(async (entity: TeamInvoiceEntity) => entity),
};
}
describe('平台总后台超级管理员 - 会员、续费与订阅服务测试 (TDD)', () => {
let service: PlatformSubscriptionService;
beforeEach(() => {
service = new PlatformSubscriptionService();
});
describe('0. 有仓库时只读库,空库不回退种子', () => {
it('注入空订阅仓库时 getSubscribers / getOverview 返回空列表与 0,不含 Vanguard/Dubai', async () => {
service.teamSubscriptionEntity = createMockSubRepo(
[]
) as unknown as PlatformSubscriptionService['teamSubscriptionEntity'];
const overview = await service.getOverview();
expect(overview.totalSubscribers).toBe(0);
expect(overview.activeSubscribers).toBe(0);
expect(overview.proCount).toBe(0);
expect(overview.enterpriseCount).toBe(0);
expect(overview.expiringSoonCount).toBe(0);
expect(overview.totalRenewalRevenue).toBe(0);
const res = await service.getSubscribers();
expect(res.list).toEqual([]);
expect(res.pagination.total).toBe(0);
const blob = JSON.stringify({ overview, res });
expect(blob).not.toMatch(/Vanguard/i);
expect(blob).not.toMatch(/Dubai/i);
expect(blob).not.toMatch(/Berlin Precision/i);
});
it('注入空发票仓库时 getRenewals 返回空列表,不含种子租户名', async () => {
service.teamInvoiceEntity = createMockInvoiceRepo(
[]
) as unknown as PlatformSubscriptionService['teamInvoiceEntity'];
const res = await service.getRenewals();
expect(res.list).toEqual([]);
expect(res.pagination.total).toBe(0);
expect(JSON.stringify(res)).not.toMatch(/Vanguard|Dubai|Berlin Precision/i);
});
it('有仓库时从库映射会员与续费,不混入内存种子', async () => {
service.teamSubscriptionEntity = createMockSubRepo([
{
id: 10,
tenantId: 88,
planType: 'pro',
status: 'active',
currentPeriodEnd: '2027-06-01',
createTime: '2026-01-01 12:00:00' as unknown as Date,
},
]) as unknown as PlatformSubscriptionService['teamSubscriptionEntity'];
service.teamInvoiceEntity = createMockInvoiceRepo([
{
id: 20,
tenantId: 88,
invoiceNo: 'INV-DB-88',
totalAmount: 490,
currency: 'USD',
status: 'paid',
paidAt: '2026-09-01 10:00:00',
title: 'Pro yearly',
},
]) as unknown as PlatformSubscriptionService['teamInvoiceEntity'];
const subs = await service.getSubscribers();
expect(subs.pagination.total).toBe(1);
expect(subs.list[0].tenantId).toBe(88);
expect(subs.list[0].planType).toBe('pro');
expect(JSON.stringify(subs)).not.toMatch(/Vanguard|Dubai/i);
const renewals = await service.getRenewals();
expect(renewals.pagination.total).toBe(1);
expect(renewals.list[0].orderNo).toBe('INV-DB-88');
expect(renewals.list[0].amount).toBe(490);
const overview = await service.getOverview();
expect(overview.totalSubscribers).toBe(1);
expect(overview.proCount).toBe(1);
expect(overview.totalRenewalRevenue).toBe(490);
});
it('有订阅仓库但找不到租户时 manualRenew 必须报错,不得造成功续费', async () => {
service.teamSubscriptionEntity = createMockSubRepo(
[]
) as unknown as PlatformSubscriptionService['teamSubscriptionEntity'];
await expect(
service.manualRenew({
tenantId: 9999,
planType: 'pro',
billingCycle: 'monthly',
amount: 49,
})
).rejects.toThrow(/9999|不存在|未找到/);
});
it('有仓库时即便内存已灌数也只读库,不回退内存种子', async () => {
seedMemory(service);
service.teamSubscriptionEntity = createMockSubRepo(
[]
) as unknown as PlatformSubscriptionService['teamSubscriptionEntity'];
service.teamInvoiceEntity = createMockInvoiceRepo(
[]
) as unknown as PlatformSubscriptionService['teamInvoiceEntity'];
const subs = await service.getSubscribers();
const renewals = await service.getRenewals();
const overview = await service.getOverview();
expect(subs.list).toEqual([]);
expect(renewals.list).toEqual([]);
expect(overview.totalSubscribers).toBe(0);
expect(overview.totalRenewalRevenue).toBe(0);
expect(JSON.stringify({ subs, renewals, overview })).not.toMatch(
/Acme Memory|Vanguard|Dubai/i
);
});
});
describe('1. 平台总览与指标概览', () => {
it('无仓库时 getOverview 基于内存数据返回指标', async () => {
seedMemory(service);
const overview = await service.getOverview();
expect(overview).toBeDefined();
expect(overview.totalSubscribers).toBeGreaterThan(0);
expect(overview.activeSubscribers).toBeGreaterThan(0);
expect(overview.proCount).toBeGreaterThanOrEqual(0);
expect(overview.enterpriseCount).toBeGreaterThanOrEqual(0);
expect(overview.totalRenewalRevenue).toBeGreaterThan(0);
});
it('无仓库且内存为空时 overview 全为 0', async () => {
const overview = await service.getOverview();
expect(overview.totalSubscribers).toBe(0);
expect(overview.activeSubscribers).toBe(0);
expect(overview.totalRenewalRevenue).toBe(0);
expect(JSON.stringify(overview)).not.toMatch(/Vanguard|Dubai/i);
});
});
describe('2. 会员列表管理 (Subscribers)', () => {
beforeEach(() => {
seedMemory(service);
});
it('getSubscribers 应支持分页查询与全量列表', async () => {
const res = await service.getSubscribers({ page: 1, size: 10 });
expect(res.list).toBeDefined();
expect(Array.isArray(res.list)).toBe(true);
expect(res.pagination.total).toBeGreaterThan(0);
expect(res.list[0]).toHaveProperty('tenantName');
expect(res.list[0]).toHaveProperty('planType');
expect(res.list[0]).toHaveProperty('cardsLimit');
expect(res.list[0]).toHaveProperty('seatsLimit');
});
it('getSubscribers 应支持按套餐类型筛选 (pro / enterprise / free)', async () => {
const proList = await service.getSubscribers({ planType: 'pro' });
for (const item of proList.list) {
expect(item.planType).toBe('pro');
}
});
it('getSubscribers 应支持按状态筛选 (active / trialing / past_due / canceled)', async () => {
const activeList = await service.getSubscribers({ status: 'active' });
for (const item of activeList.list) {
expect(item.status).toBe('active');
}
});
it('getSubscribers 应支持按关键词搜索企业名称或邮箱', async () => {
const all = await service.getSubscribers();
const first = all.list[0];
const searchRes = await service.getSubscribers({ keyword: first.adminEmail });
expect(searchRes.list.some(s => s.tenantId === first.tenantId)).toBe(true);
});
it('updateSubscriber 应支持超管修改会员套餐、配额与状态', async () => {
const updated = await service.updateSubscriber({
tenantId: 1,
planType: 'enterprise',
cardsLimit: 88,
seatsLimit: 30,
status: 'active',
});
expect(updated.planType).toBe('enterprise');
expect(updated.cardsLimit).toBe(88);
expect(updated.seatsLimit).toBe(30);
});
it('updateSubscriber 应支持延长会员有效期 (extendDays)', async () => {
const before = (await service.getSubscribers({ keyword: '1' })).list.find(
s => s.tenantId === 1
);
const updated = await service.updateSubscriber({
tenantId: 1,
extendDays: 30,
});
expect(updated.expireDate).toBeDefined();
if (before?.expireDate) {
expect(new Date(updated.expireDate).getTime()).toBeGreaterThan(
new Date(before.expireDate).getTime()
);
}
});
it('updateSubscriber 找不到租户时应报错', async () => {
await expect(
service.updateSubscriber({ tenantId: 40404, status: 'canceled' })
).rejects.toThrow(/40404|未找到/);
});
});
describe('3. 续费流水管理与手动代续费 (Renewals)', () => {
beforeEach(() => {
seedMemory(service);
});
it('getRenewals 应返回全网订单续费流水', async () => {
const res = await service.getRenewals({ page: 1, size: 10 });
expect(res.list).toBeDefined();
expect(Array.isArray(res.list)).toBe(true);
expect(res.list.length).toBeGreaterThan(0);
expect(res.list[0]).toHaveProperty('orderNo');
expect(res.list[0]).toHaveProperty('amount');
expect(res.list[0]).toHaveProperty('channel');
});
it('manualRenew 应允许超级管理员手动为会员办理续费并生成流水与延期', async () => {
const renewRes = await service.manualRenew({
tenantId: 2,
planType: 'pro',
billingCycle: 'yearly',
amount: 588,
currency: 'USD',
channel: 'manual_admin',
note: '总后台超级管理员代续费测试',
});
expect(renewRes.success).toBe(true);
expect(renewRes.orderNo).toMatch(/^ORD-/);
const sub = (await service.getSubscribers({ keyword: '2' })).list.find(
s => s.tenantId === 2
);
expect(sub?.planType).toBe('pro');
});
it('manualRenew 找不到租户时必须报错,不得返回成功', async () => {
await expect(
service.manualRenew({
tenantId: 7777,
planType: 'pro',
billingCycle: 'monthly',
amount: 49,
})
).rejects.toThrow(/7777|不存在|未找到/);
});
});
describe('4. SaaS 套餐定价与规格矩阵配置 (Plans)', () => {
it('getPlans 应返回 Free, Pro, Enterprise 套餐规格', async () => {
const plans = await service.getPlans();
expect(plans.length).toBe(3);
expect(plans.map(p => p.planType)).toEqual(
expect.arrayContaining(['free', 'pro', 'enterprise'])
);
});
it('updatePlan 应支持更新套餐定价与限额', async () => {
const updated = await service.updatePlan('pro', {
monthlyPrice: 59,
yearlyPrice: 599,
cardsLimit: 15,
});
expect(updated.monthlyPrice).toBe(59);
expect(updated.yearlyPrice).toBe(599);
expect(updated.cardsLimit).toBe(15);
});
});
describe('5. 控制器路由与契约完整性', () => {
it('AdminPlatformSubscriptionController 必须正确导出且声明各总控方法', () => {
const controller = new AdminPlatformSubscriptionController();
expect(controller).toBeDefined();
expect(typeof controller.overview).toBe('function');
expect(typeof controller.subscribersPage).toBe('function');
expect(typeof controller.subscribersUpdate).toBe('function');
expect(typeof controller.renewalsPage).toBe('function');
expect(typeof controller.manualRenew).toBe('function');
expect(typeof controller.plansList).toBe('function');
expect(typeof controller.plansUpdate).toBe('function');
});
});
describe('6. 生产路径自审:源码不得再把种子当默认返回', () => {
it('默认构造的服务列表/流水不含 Vanguard / Dubai Apex', async () => {
const bare = new PlatformSubscriptionService();
const subs = await bare.getSubscribers();
const renewals = await bare.getRenewals();
const overview = await bare.getOverview();
const blob = JSON.stringify({ subs, renewals, overview });
expect(blob).not.toMatch(/Vanguard/i);
expect(blob).not.toMatch(/Dubai Apex/i);
expect(blob).not.toMatch(/Berlin Precision/i);
expect(subs.list).toEqual([]);
expect(renewals.list).toEqual([]);
});
it('platform-subscription.ts 源码不得再硬编码 Vanguard / Dubai 种子租户', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs') as typeof import('fs');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path') as typeof import('path');
const src = fs.readFileSync(
path.join(
__dirname,
'../../../src/modules/team/service/platform-subscription.ts'
),
'utf8'
);
expect(src).not.toMatch(/Vanguard/i);
expect(src).not.toMatch(/Dubai/i);
expect(src).not.toMatch(/Berlin Precision/i);
});
});
});