补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
498 行
17 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|