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[] = []) { 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[] = []) { 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); }); }); });