feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
@@ -1,3 +1,4 @@
|
||||
import * as dns from 'dns';
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
import { BaseEntity } from '../../../src/comm/entity/base';
|
||||
@@ -171,13 +172,39 @@ describe('Point 22: 独立企业二级域名绑定向导与 SSL 证书检测 单
|
||||
expect(res.message).toContain('不匹配');
|
||||
});
|
||||
|
||||
it('模拟或真实解析发生空记录或错误时友好降级为 verified=false', async () => {
|
||||
it('显式传入空模拟记录时 verified=false 且不假装已核验', async () => {
|
||||
const res = await service.verifyDnsRecord('card.mycompany.com', 'cname.omnicard.ai', {
|
||||
mockRecords: [],
|
||||
});
|
||||
|
||||
expect(res.verified).toBe(false);
|
||||
expect(res.records).toEqual([]);
|
||||
expect(res.message).toMatch(/未找到/);
|
||||
});
|
||||
|
||||
it('不传模拟记录且 DNS 解析失败时 verified 为 false', async () => {
|
||||
const spy = jest
|
||||
.spyOn(dns.promises, 'resolveCname')
|
||||
.mockRejectedValue(
|
||||
Object.assign(new Error('queryCname ENOTFOUND card.mycompany.com'), {
|
||||
code: 'ENOTFOUND',
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await service.verifyDnsRecord(
|
||||
'card.mycompany.com',
|
||||
'cname.omnicard.ai'
|
||||
);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('card.mycompany.com');
|
||||
expect(res.verified).toBe(false);
|
||||
expect(res.records).toEqual([]);
|
||||
expect(res.message).toMatch(/解析失败/);
|
||||
expect(res.message).toMatch(/ENOTFOUND/);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -82,4 +82,171 @@ describe('【财务与运营新账统计】TeamFinanceAnalyticsService 单元测
|
||||
expect(response.code).toBe(1000);
|
||||
expect(response.data.summary.mrr).toBeDefined();
|
||||
});
|
||||
|
||||
it('6. 无发票/无订阅可归属时 regionalRevenue 与 recentLedger 必须为空数组,不得填演示数据', async () => {
|
||||
service.teamInvoiceEntity = {
|
||||
find: async () => [],
|
||||
} as unknown as TeamFinanceAnalyticsService['teamInvoiceEntity'];
|
||||
service.teamSubscriptionEntity = {
|
||||
find: async () => [],
|
||||
} as unknown as TeamFinanceAnalyticsService['teamSubscriptionEntity'];
|
||||
|
||||
const result = await service.getOverviewAnalytics({ period: 'all' });
|
||||
|
||||
expect(result.regionalRevenue).toEqual([]);
|
||||
expect(result.recentLedger).toEqual([]);
|
||||
|
||||
const ledgerDump = JSON.stringify(result.recentLedger);
|
||||
expect(ledgerDump).not.toMatch(/Vanguard|Dubai Apex|Berlin Precision|Silicon Horizon|Nordic Clean/i);
|
||||
|
||||
const regionPercents = result.regionalRevenue.map((r) => r.percentage);
|
||||
expect(regionPercents).not.toEqual([46, 28, 14, 12]);
|
||||
});
|
||||
|
||||
it('7. 区域占比必须按已有发票币种归属真实汇总,百分比来自实际收款占比而非固定比例', async () => {
|
||||
const invoices: Partial<TeamInvoiceEntity>[] = [
|
||||
{
|
||||
id: 11,
|
||||
invoiceNo: 'INV-20260921-0001',
|
||||
currency: 'USD',
|
||||
totalAmount: 1000,
|
||||
taxAmount: 0,
|
||||
status: 'paid',
|
||||
title: 'Pro Plan',
|
||||
billingInfo: { companyName: 'Acme North LLC' },
|
||||
tenantId: 101,
|
||||
createTime: new Date('2026-09-10T10:00:00Z') as unknown as Date,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
invoiceNo: 'INV-20260921-0002',
|
||||
currency: 'EUR',
|
||||
totalAmount: 500,
|
||||
taxAmount: 0,
|
||||
status: 'paid',
|
||||
title: 'Enterprise Plan',
|
||||
billingInfo: { companyName: 'Rhine Tools GmbH' },
|
||||
tenantId: 102,
|
||||
createTime: new Date('2026-09-11T10:00:00Z') as unknown as Date,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
invoiceNo: 'INV-20260921-0003',
|
||||
currency: 'AED',
|
||||
totalAmount: 500,
|
||||
taxAmount: 0,
|
||||
status: 'paid',
|
||||
title: 'Pro Plan',
|
||||
billingInfo: { companyName: 'Gulf Bridge Trading' },
|
||||
tenantId: 103,
|
||||
createTime: new Date('2026-09-12T10:00:00Z') as unknown as Date,
|
||||
},
|
||||
];
|
||||
|
||||
service.teamInvoiceEntity = {
|
||||
find: async () => invoices as TeamInvoiceEntity[],
|
||||
} as unknown as TeamFinanceAnalyticsService['teamInvoiceEntity'];
|
||||
service.teamSubscriptionEntity = {
|
||||
find: async () => [],
|
||||
} as unknown as TeamFinanceAnalyticsService['teamSubscriptionEntity'];
|
||||
|
||||
const result = await service.getOverviewAnalytics({ period: 'all' });
|
||||
|
||||
expect(result.regionalRevenue.length).toBeGreaterThanOrEqual(1);
|
||||
const totalPct = result.regionalRevenue.reduce((s, r) => s + r.percentage, 0);
|
||||
expect(totalPct).toBeCloseTo(100, 0);
|
||||
|
||||
const byRegion = Object.fromEntries(
|
||||
result.regionalRevenue.map((r) => [r.region, r])
|
||||
);
|
||||
// USD 1000 / 2000 = 50%, EUR 500 = 25%, AED 500 = 25%
|
||||
const usdRegion = Object.values(byRegion).find((r) => r.revenue === 1000);
|
||||
const eurRegion = Object.values(byRegion).find((r) => r.revenue === 500 && r.percentage === 25);
|
||||
expect(usdRegion?.percentage).toBe(50);
|
||||
expect(eurRegion).toBeDefined();
|
||||
|
||||
const fixedDemo = result.regionalRevenue.map((r) => r.percentage);
|
||||
expect(fixedDemo).not.toEqual([46, 28, 14, 12]);
|
||||
});
|
||||
|
||||
it('8. recentLedger 只能映射已有发票字段,租户名取自 billingInfo,不得出现演示租户', async () => {
|
||||
const invoices: Partial<TeamInvoiceEntity>[] = [
|
||||
{
|
||||
id: 21,
|
||||
invoiceNo: 'INV-20260920-0100',
|
||||
currency: 'USD',
|
||||
totalAmount: 196,
|
||||
taxAmount: 0,
|
||||
status: 'paid',
|
||||
title: 'Pro Seat Addon',
|
||||
billingInfo: { companyName: 'Orbit Studio Co.' },
|
||||
tenantId: 201,
|
||||
createTime: new Date('2026-09-20T08:00:00Z') as unknown as Date,
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
invoiceNo: 'INV-20260919-0099',
|
||||
currency: 'EUR',
|
||||
totalAmount: 588,
|
||||
taxAmount: 0,
|
||||
status: 'issued',
|
||||
title: 'Enterprise Renewal',
|
||||
billingInfo: { companyName: 'Nordic Labs AB' },
|
||||
tenantId: 202,
|
||||
createTime: new Date('2026-09-19T08:00:00Z') as unknown as Date,
|
||||
},
|
||||
];
|
||||
|
||||
service.teamInvoiceEntity = {
|
||||
find: async () => invoices as TeamInvoiceEntity[],
|
||||
} as unknown as TeamFinanceAnalyticsService['teamInvoiceEntity'];
|
||||
service.teamSubscriptionEntity = {
|
||||
find: async () => [],
|
||||
} as unknown as TeamFinanceAnalyticsService['teamSubscriptionEntity'];
|
||||
|
||||
const result = await service.getOverviewAnalytics({ period: 'all' });
|
||||
|
||||
expect(result.recentLedger.length).toBe(2);
|
||||
expect(result.recentLedger.map((l) => l.invoiceNo)).toEqual([
|
||||
'INV-20260920-0100',
|
||||
'INV-20260919-0099',
|
||||
]);
|
||||
expect(result.recentLedger[0].tenantName).toBe('Orbit Studio Co.');
|
||||
expect(result.recentLedger[1].tenantName).toBe('Nordic Labs AB');
|
||||
expect(result.recentLedger[0].amount).toBe(196);
|
||||
expect(result.recentLedger[1].status).toBe('issued');
|
||||
|
||||
const names = result.recentLedger.map((l) => l.tenantName).join(' ');
|
||||
expect(names).not.toMatch(/Vanguard|Dubai Apex|Berlin Precision|Silicon Horizon/i);
|
||||
});
|
||||
|
||||
it('9. 仅有未付款发票时区域营收为空,流水仍可来自真实发票', async () => {
|
||||
const invoices: Partial<TeamInvoiceEntity>[] = [
|
||||
{
|
||||
id: 31,
|
||||
invoiceNo: 'INV-20260918-0001',
|
||||
currency: 'USD',
|
||||
totalAmount: 299,
|
||||
taxAmount: 0,
|
||||
status: 'issued',
|
||||
title: 'Pro Plan',
|
||||
billingInfo: { companyName: 'Pending Buyer Inc.' },
|
||||
tenantId: 301,
|
||||
createTime: new Date('2026-09-18T08:00:00Z') as unknown as Date,
|
||||
},
|
||||
];
|
||||
|
||||
service.teamInvoiceEntity = {
|
||||
find: async () => invoices as TeamInvoiceEntity[],
|
||||
} as unknown as TeamFinanceAnalyticsService['teamInvoiceEntity'];
|
||||
service.teamSubscriptionEntity = {
|
||||
find: async () => [],
|
||||
} as unknown as TeamFinanceAnalyticsService['teamSubscriptionEntity'];
|
||||
|
||||
const result = await service.getOverviewAnalytics({ period: 'all' });
|
||||
expect(result.regionalRevenue).toEqual([]);
|
||||
expect(result.recentLedger).toHaveLength(1);
|
||||
expect(result.recentLedger[0].tenantName).toBe('Pending Buyer Inc.');
|
||||
expect(result.recentLedger[0].invoiceNo).toBe('INV-20260918-0001');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -361,6 +361,136 @@ describe('Point 24: 国际多币种发票生成与历史收据导出 单元测
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 6b. 发票下载真实文件内容 (P38)
|
||||
// ==========================================
|
||||
describe('6b. 发票下载真实文件内容 (P38)', () => {
|
||||
let service: TeamInvoiceService;
|
||||
let invoices: TeamInvoiceEntity[];
|
||||
let nextId = 1;
|
||||
|
||||
beforeEach(() => {
|
||||
invoices = [];
|
||||
nextId = 1;
|
||||
|
||||
const mockRepo = {
|
||||
count: jest.fn(async () => invoices.length),
|
||||
save: jest.fn(async (entity: TeamInvoiceEntity) => {
|
||||
const saved = Object.assign(new TeamInvoiceEntity(), entity, {
|
||||
id: nextId++,
|
||||
createTime: new Date(),
|
||||
updateTime: new Date(),
|
||||
});
|
||||
invoices.push(saved);
|
||||
return saved;
|
||||
}),
|
||||
find: jest.fn(async (options?: { where?: Partial<TeamInvoiceEntity> }) => {
|
||||
if (!options?.where) return [...invoices];
|
||||
return invoices.filter(item => {
|
||||
const itemMap = item as unknown as Record<string, unknown>;
|
||||
for (const [key, val] of Object.entries(options.where!)) {
|
||||
if (itemMap[key] !== val) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}),
|
||||
findOne: jest.fn(
|
||||
async (options?: { where?: Partial<TeamInvoiceEntity> }) => {
|
||||
if (!options?.where) return null;
|
||||
const found = invoices.find(item => {
|
||||
const itemMap = item as unknown as Record<string, unknown>;
|
||||
for (const [key, val] of Object.entries(options.where!)) {
|
||||
if (itemMap[key] !== val) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return found || null;
|
||||
}
|
||||
),
|
||||
findOneBy: jest.fn(async (where: Partial<TeamInvoiceEntity>) => {
|
||||
const found = invoices.find(item => {
|
||||
const itemMap = item as unknown as Record<string, unknown>;
|
||||
for (const [key, val] of Object.entries(where)) {
|
||||
if (itemMap[key] !== val) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return found || null;
|
||||
}),
|
||||
};
|
||||
|
||||
service = new TeamInvoiceService();
|
||||
service.teamInvoiceEntity =
|
||||
mockRepo as unknown as TeamInvoiceService['teamInvoiceEntity'];
|
||||
});
|
||||
|
||||
it('downloadInvoice 发票不存在时应抛 CoolCommException,不得返回成功 URL', async () => {
|
||||
await expect(service.downloadInvoice(99999, 101)).rejects.toThrow(
|
||||
CoolCommException
|
||||
);
|
||||
await expect(service.downloadInvoice(99999, 101)).rejects.toThrow(
|
||||
/不存在|not found/i
|
||||
);
|
||||
});
|
||||
|
||||
it('downloadInvoice 应返回真实文件内容,且含发票号与金额', async () => {
|
||||
const created = await service.generateInvoice(101, {
|
||||
subtotal: 200,
|
||||
taxRate: 0.1,
|
||||
currency: 'USD',
|
||||
title: 'Enterprise Plan',
|
||||
});
|
||||
|
||||
const result = await service.downloadInvoice(created.id, 101);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.content).toBeTruthy();
|
||||
expect(typeof result.content).toBe('string');
|
||||
expect(result.content.length).toBeGreaterThan(20);
|
||||
expect(result.content).toContain(created.invoiceNo);
|
||||
expect(result.content).toMatch(/220\.00|\$220/);
|
||||
expect(result.filename).toMatch(
|
||||
new RegExp(`${created.invoiceNo}\\.(txt|pdf)$`)
|
||||
);
|
||||
expect(result.contentType).toMatch(/text\/plain|application\/pdf/);
|
||||
expect(result.invoiceNo).toBe(created.invoiceNo);
|
||||
// 禁止假 CDN / 空壳外链
|
||||
expect(result).not.toHaveProperty('url');
|
||||
expect(result.content).not.toMatch(
|
||||
/cdn\.omnicard|https?:\/\/.*invoice.*\.pdf/i
|
||||
);
|
||||
});
|
||||
|
||||
it('downloadInvoice 跨租户或无权访问时应报错,不得返回成功 URL', async () => {
|
||||
const created = await service.generateInvoice(101, {
|
||||
subtotal: 50,
|
||||
currency: 'USD',
|
||||
title: 'Cross-tenant check',
|
||||
});
|
||||
|
||||
await expect(service.downloadInvoice(created.id, 202)).rejects.toThrow(
|
||||
CoolCommException
|
||||
);
|
||||
});
|
||||
|
||||
it('downloadInvoice 在无 Stripe 发票号时不得假装已从 Stripe 拉取 PDF', async () => {
|
||||
const created = await service.generateInvoice(101, {
|
||||
subtotal: 88,
|
||||
currency: 'EUR',
|
||||
title: 'No Stripe PDF',
|
||||
});
|
||||
|
||||
const result = await service.downloadInvoice(created.id, 101);
|
||||
expect(result.content).toContain(created.invoiceNo);
|
||||
expect(result.content).not.toMatch(
|
||||
/stripe\.com|invoicestripes|invoice_pdf/i
|
||||
);
|
||||
expect(JSON.stringify(result)).not.toMatch(
|
||||
/https:\/\/files\.stripe\.com|invoice_pdf/i
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 7. 控制器接口调用测试 (AdminTeamBillingController)
|
||||
// ==========================================
|
||||
@@ -389,6 +519,14 @@ describe('Point 24: 国际多币种发票生成与历史收据导出 单元测
|
||||
return [inv];
|
||||
});
|
||||
|
||||
invoiceService.downloadInvoice = jest.fn(async () => ({
|
||||
content:
|
||||
'INVOICE / RECEIPT\nInvoice No : INV-20260920-0001\nTOTAL AMOUNT : $110.00\n',
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
filename: 'INV-20260920-0001.txt',
|
||||
invoiceNo: 'INV-20260920-0001',
|
||||
}));
|
||||
|
||||
controller = new AdminTeamBillingController();
|
||||
controller.teamInvoiceService = invoiceService;
|
||||
controller.ok = (data: unknown) => ({
|
||||
@@ -422,5 +560,32 @@ describe('Point 24: 国际多币种发票生成与历史收据导出 单元测
|
||||
expect(res.code).toBe(1000);
|
||||
expect(invoiceService.listInvoices).toHaveBeenCalledWith(888);
|
||||
});
|
||||
|
||||
it('POST /invoice/download 应按 id 下载真实文件内容并委派 service', async () => {
|
||||
const res = (await controller.downloadInvoice({ id: 1 })) as {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
content: string;
|
||||
filename: string;
|
||||
invoiceNo: string;
|
||||
contentType: string;
|
||||
};
|
||||
};
|
||||
expect(res.code).toBe(1000);
|
||||
expect(invoiceService.downloadInvoice).toHaveBeenCalledWith(1, 888);
|
||||
expect(res.data.content).toContain('INV-20260920-0001');
|
||||
expect(res.data.content).toMatch(/110\.00|\$110/);
|
||||
expect(res.data).not.toHaveProperty('url');
|
||||
});
|
||||
|
||||
it('POST /invoice/download 缺少 id 时应抛 CoolCommException', async () => {
|
||||
await expect(controller.downloadInvoice({})).rejects.toThrow(
|
||||
CoolCommException
|
||||
);
|
||||
await expect(controller.downloadInvoice({ id: 0 })).rejects.toThrow(
|
||||
CoolCommException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,172 @@
|
||||
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;
|
||||
@@ -8,8 +175,122 @@ describe('平台总后台超级管理员 - 会员、续费与订阅服务测试
|
||||
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 () => {
|
||||
it('无仓库时 getOverview 基于内存数据返回指标', async () => {
|
||||
seedMemory(service);
|
||||
const overview = await service.getOverview();
|
||||
expect(overview).toBeDefined();
|
||||
expect(overview.totalSubscribers).toBeGreaterThan(0);
|
||||
@@ -18,9 +299,21 @@ describe('平台总后台超级管理员 - 会员、续费与订阅服务测试
|
||||
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();
|
||||
@@ -67,19 +360,33 @@ describe('平台总后台超级管理员 - 会员、续费与订阅服务测试
|
||||
});
|
||||
|
||||
it('updateSubscriber 应支持延长会员有效期 (extendDays)', async () => {
|
||||
const before = (await service.getSubscribers({ keyword: '1' })).list.find(s => s.tenantId === 1);
|
||||
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());
|
||||
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();
|
||||
@@ -103,17 +410,31 @@ describe('平台总后台超级管理员 - 会员、续费与订阅服务测试
|
||||
expect(renewRes.success).toBe(true);
|
||||
expect(renewRes.orderNo).toMatch(/^ORD-/);
|
||||
|
||||
// 验证会员列表里的到期时间与套餐已相应更新
|
||||
const sub = (await service.getSubscribers({ keyword: '2' })).list.find(s => s.tenantId === 2);
|
||||
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']));
|
||||
expect(plans.map(p => p.planType)).toEqual(
|
||||
expect.arrayContaining(['free', 'pro', 'enterprise'])
|
||||
);
|
||||
});
|
||||
|
||||
it('updatePlan 应支持更新套餐定价与限额', async () => {
|
||||
@@ -141,4 +462,36 @@ describe('平台总后台超级管理员 - 会员、续费与订阅服务测试
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
TeamQuotaService,
|
||||
DEFAULT_PLAN_PRICES,
|
||||
DEFAULT_PLAN_LIMITS,
|
||||
} from '../../../src/modules/team/service/quota';
|
||||
import type {
|
||||
PlanLimits,
|
||||
@@ -196,23 +197,23 @@ describe('Point 23: 企业多坐席配额用量预警与升级差价计算 单
|
||||
});
|
||||
});
|
||||
|
||||
it('3.2 当前用量已达上限时,抛出 RESOURCE_LIMIT_EXCEEDED 阻断异常', async () => {
|
||||
it('3.2 当前用量已达上限时,抛出 CoolCommException 且中文说明已达上限', async () => {
|
||||
await expect(
|
||||
quotaService.assertQuotaAvailable(101, 'seats', 1, {
|
||||
plan: 'free',
|
||||
currentUsed: 1,
|
||||
})
|
||||
).rejects.toThrow('RESOURCE_LIMIT_EXCEEDED');
|
||||
).rejects.toThrow(/已达上限/);
|
||||
});
|
||||
|
||||
it('3.3 增加批次数量 (additionalCount) 导致超限时,同样抛出 RESOURCE_LIMIT_EXCEEDED', async () => {
|
||||
it('3.3 增加批次数量 (additionalCount) 导致超限时,同样抛出已达上限', async () => {
|
||||
// 当前 8/10,增加 3 个 => 11 > 10,应拦截阻断
|
||||
await expect(
|
||||
quotaService.assertQuotaAvailable(101, 'seats', 3, {
|
||||
plan: 'pro',
|
||||
currentUsed: 8,
|
||||
})
|
||||
).rejects.toThrow('RESOURCE_LIMIT_EXCEEDED');
|
||||
).rejects.toThrow(/已达上限/);
|
||||
|
||||
// 增加 2 个 => 10 <= 10,刚好达标,不抛出
|
||||
await expect(
|
||||
@@ -223,7 +224,7 @@ describe('Point 23: 企业多坐席配额用量预警与升级差价计算 单
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('3.4 异常类型必须为 CoolCommException', async () => {
|
||||
it('3.4 异常类型必须为 CoolCommException,名片超限文案含已达上限', async () => {
|
||||
try {
|
||||
await quotaService.assertQuotaAvailable(101, 'cards', 1, {
|
||||
plan: 'free',
|
||||
@@ -232,7 +233,129 @@ describe('Point 23: 企业多坐席配额用量预警与升级差价计算 单
|
||||
fail('应当抛出异常');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CoolCommException);
|
||||
expect((err as CoolCommException).message).toContain('RESOURCE_LIMIT_EXCEEDED');
|
||||
expect((err as CoolCommException).message).toContain('已达上限');
|
||||
expect((err as CoolCommException).message).toContain('名片');
|
||||
}
|
||||
});
|
||||
|
||||
it('3.5 P33: Free 套餐名片配额用尽时阻断新增,文案为中文已达上限', async () => {
|
||||
await expect(
|
||||
quotaService.assertQuotaAvailable(101, 'cards', 1, {
|
||||
plan: 'free',
|
||||
currentUsed: 1,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: '当前套餐名片配额已达上限,请升级套餐',
|
||||
});
|
||||
});
|
||||
|
||||
it('3.6 P33: Pro 套餐名片未超限时允许新增', async () => {
|
||||
await expect(
|
||||
quotaService.assertQuotaAvailable(101, 'cards', 1, {
|
||||
plan: 'pro',
|
||||
currentUsed: 4,
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
used: 4,
|
||||
limit: 5,
|
||||
exceeded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('3.7 R45: Free 名片上限必须来自套餐配置,按配置值放行', async () => {
|
||||
expect(DEFAULT_PLAN_LIMITS.free.cardsLimit).toBe(1);
|
||||
await expect(
|
||||
quotaService.assertQuotaAvailable(101, 'cards', 1, {
|
||||
plan: 'free',
|
||||
currentUsed: 0,
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
used: 0,
|
||||
limit: DEFAULT_PLAN_LIMITS.free.cardsLimit,
|
||||
exceeded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('3.8 R45: Free 套餐未配置 cardsLimit 时拒绝新增,不得当成无限', async () => {
|
||||
const original = DEFAULT_PLAN_LIMITS.free.cardsLimit;
|
||||
try {
|
||||
delete (DEFAULT_PLAN_LIMITS.free as { cardsLimit?: number }).cardsLimit;
|
||||
await expect(
|
||||
quotaService.assertQuotaAvailable(101, 'cards', 1, {
|
||||
plan: 'free',
|
||||
currentUsed: 0,
|
||||
})
|
||||
).rejects.toThrow(/未配置|未设置/);
|
||||
await expect(
|
||||
quotaService.assertQuotaAvailable(101, 'cards', 1, {
|
||||
plan: 'free',
|
||||
currentUsed: 0,
|
||||
})
|
||||
).rejects.toBeInstanceOf(CoolCommException);
|
||||
} finally {
|
||||
DEFAULT_PLAN_LIMITS.free.cardsLimit = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('3.9 R45: Free 套餐 cardsLimit 为 NaN/Infinity 视为未设置上限,拒绝新增', async () => {
|
||||
const original = DEFAULT_PLAN_LIMITS.free.cardsLimit;
|
||||
try {
|
||||
DEFAULT_PLAN_LIMITS.free.cardsLimit = Number.NaN;
|
||||
await expect(
|
||||
quotaService.assertQuotaAvailable(101, 'cards', 1, {
|
||||
plan: 'free',
|
||||
currentUsed: 0,
|
||||
})
|
||||
).rejects.toThrow(/未配置|未设置/);
|
||||
|
||||
DEFAULT_PLAN_LIMITS.free.cardsLimit = Number.POSITIVE_INFINITY;
|
||||
await expect(
|
||||
quotaService.assertQuotaAvailable(101, 'cards', 1, {
|
||||
plan: 'free',
|
||||
currentUsed: 0,
|
||||
})
|
||||
).rejects.toThrow(/未配置|未设置/);
|
||||
} finally {
|
||||
DEFAULT_PLAN_LIMITS.free.cardsLimit = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('3.10 R45: 未配置上限的拒绝文案不得冒充 P33 配额用尽文案', async () => {
|
||||
const original = DEFAULT_PLAN_LIMITS.free.cardsLimit;
|
||||
try {
|
||||
delete (DEFAULT_PLAN_LIMITS.free as { cardsLimit?: number }).cardsLimit;
|
||||
try {
|
||||
await quotaService.assertQuotaAvailable(101, 'cards', 1, {
|
||||
plan: 'free',
|
||||
currentUsed: 0,
|
||||
});
|
||||
fail('应当抛出异常');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CoolCommException);
|
||||
expect((err as CoolCommException).message).not.toBe(
|
||||
'当前套餐名片配额已达上限,请升级套餐'
|
||||
);
|
||||
expect((err as CoolCommException).message).toMatch(/未配置|未设置/);
|
||||
}
|
||||
} finally {
|
||||
DEFAULT_PLAN_LIMITS.free.cardsLimit = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('3.11 R45: checkQuota 在未配置上限时 exceeded=true、remaining=0,不得当成无限', async () => {
|
||||
const original = DEFAULT_PLAN_LIMITS.free.cardsLimit;
|
||||
try {
|
||||
delete (DEFAULT_PLAN_LIMITS.free as { cardsLimit?: number }).cardsLimit;
|
||||
const result = await quotaService.checkQuota(101, 'cards', {
|
||||
plan: 'free',
|
||||
currentUsed: 0,
|
||||
});
|
||||
expect(result.limit).toBe(0);
|
||||
expect(result.remaining).toBe(0);
|
||||
expect(result.exceeded).toBe(true);
|
||||
expect(result.percentage).toBe(100);
|
||||
} finally {
|
||||
DEFAULT_PLAN_LIMITS.free.cardsLimit = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
import { TeamStripeService } from '../../../src/modules/team/service/stripe';
|
||||
import { TeamSubscriptionEntity } from '../../../src/modules/team/entity/subscription';
|
||||
@@ -8,56 +9,121 @@ import {
|
||||
StripeWebhookEvent,
|
||||
} from '../../../src/comm/types/omni';
|
||||
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入账', () => {
|
||||
let stripeService: TeamStripeService;
|
||||
let subscriptions: TeamSubscriptionEntity[];
|
||||
let processedStripeEvents: { id?: number; eventId: string; eventType?: string }[];
|
||||
const testWebhookSecret = 'whsec_test_secret_key_1234567890abcdef';
|
||||
const originalStripeSecret = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
const createMockSubscriptionRepo = () => ({
|
||||
findOne: jest.fn(async ({ where }: { where: { tenantId?: number | string; stripeSubscriptionId?: string } }) => {
|
||||
return subscriptions.find(s => {
|
||||
if (where.tenantId !== undefined && s.tenantId === Number(where.tenantId)) return true;
|
||||
if (where.stripeSubscriptionId && s.stripeSubscriptionId === where.stripeSubscriptionId) return true;
|
||||
return false;
|
||||
}) || null;
|
||||
}),
|
||||
findOneBy: jest.fn(async (q: { tenantId?: number | string; stripeSubscriptionId?: string }) => {
|
||||
return subscriptions.find(s => {
|
||||
if (q.tenantId !== undefined && s.tenantId === Number(q.tenantId)) return true;
|
||||
if (q.stripeSubscriptionId && s.stripeSubscriptionId === q.stripeSubscriptionId) return true;
|
||||
return false;
|
||||
}) || null;
|
||||
}),
|
||||
save: jest.fn(async (entity: TeamSubscriptionEntity) => {
|
||||
if (!entity.id) {
|
||||
entity.id = subscriptions.length ? Math.max(...subscriptions.map(s => s.id || 0)) + 1 : 1;
|
||||
subscriptions.push(entity);
|
||||
return entity;
|
||||
}
|
||||
const idx = subscriptions.findIndex(s => s.id === entity.id);
|
||||
if (idx >= 0) {
|
||||
Object.assign(subscriptions[idx], entity);
|
||||
return subscriptions[idx];
|
||||
}
|
||||
subscriptions.push(entity);
|
||||
return entity;
|
||||
}),
|
||||
});
|
||||
|
||||
const createMockStripeEventRepo = () => ({
|
||||
findOne: jest.fn(async ({ where }: { where: { eventId?: string } }) => {
|
||||
return processedStripeEvents.find(e => e.eventId === where.eventId) || null;
|
||||
}),
|
||||
save: jest.fn(async (entity: { id?: number; eventId: string; eventType?: string }) => {
|
||||
const existing = processedStripeEvents.find(e => e.eventId === entity.eventId);
|
||||
if (existing) {
|
||||
Object.assign(existing, entity);
|
||||
return existing;
|
||||
}
|
||||
entity.id =
|
||||
processedStripeEvents.length
|
||||
? Math.max(...processedStripeEvents.map(e => e.id || 0)) + 1
|
||||
: 1;
|
||||
processedStripeEvents.push(entity);
|
||||
return entity;
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
subscriptions = [];
|
||||
|
||||
// Mock Subscription Repository
|
||||
const mockSubscriptionRepo = {
|
||||
findOne: jest.fn(async ({ where }: { where: { tenantId?: number | string; stripeSubscriptionId?: string } }) => {
|
||||
return subscriptions.find(s => {
|
||||
if (where.tenantId !== undefined && s.tenantId === Number(where.tenantId)) return true;
|
||||
if (where.stripeSubscriptionId && s.stripeSubscriptionId === where.stripeSubscriptionId) return true;
|
||||
return false;
|
||||
}) || null;
|
||||
}),
|
||||
findOneBy: jest.fn(async (q: { tenantId?: number | string; stripeSubscriptionId?: string }) => {
|
||||
return subscriptions.find(s => {
|
||||
if (q.tenantId !== undefined && s.tenantId === Number(q.tenantId)) return true;
|
||||
if (q.stripeSubscriptionId && s.stripeSubscriptionId === q.stripeSubscriptionId) return true;
|
||||
return false;
|
||||
}) || null;
|
||||
}),
|
||||
save: jest.fn(async (entity: TeamSubscriptionEntity) => {
|
||||
if (!entity.id) {
|
||||
entity.id = subscriptions.length ? Math.max(...subscriptions.map(s => s.id || 0)) + 1 : 1;
|
||||
subscriptions.push(entity);
|
||||
return entity;
|
||||
}
|
||||
const idx = subscriptions.findIndex(s => s.id === entity.id);
|
||||
if (idx >= 0) {
|
||||
Object.assign(subscriptions[idx], entity);
|
||||
return subscriptions[idx];
|
||||
}
|
||||
subscriptions.push(entity);
|
||||
return entity;
|
||||
}),
|
||||
};
|
||||
processedStripeEvents = [];
|
||||
delete process.env.STRIPE_SECRET_KEY;
|
||||
mockedAxios.post.mockReset();
|
||||
|
||||
stripeService = new TeamStripeService();
|
||||
stripeService.teamSubscriptionEntity = mockSubscriptionRepo as unknown as TeamStripeService['teamSubscriptionEntity'];
|
||||
stripeService.teamSubscriptionEntity =
|
||||
createMockSubscriptionRepo() as unknown as TeamStripeService['teamSubscriptionEntity'];
|
||||
stripeService.teamStripeEventEntity =
|
||||
createMockStripeEventRepo() as unknown as TeamStripeService['teamStripeEventEntity'];
|
||||
stripeService.webhookSecret = testWebhookSecret;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalStripeSecret === undefined) {
|
||||
delete process.env.STRIPE_SECRET_KEY;
|
||||
} else {
|
||||
process.env.STRIPE_SECRET_KEY = originalStripeSecret;
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 1. Checkout 会话创建 (createCheckoutSession)
|
||||
// =========================================================================
|
||||
describe('1. Checkout 会话创建 (createCheckoutSession)', () => {
|
||||
it('1.1 成功为租户创建 Pro 月付结算会话,返回模拟/真实 Stripe URL 与 sessionId', async () => {
|
||||
it('1.0 未配置 STRIPE_SECRET_KEY 时抛出 CoolCommException,不返回 sessionId/url', async () => {
|
||||
await expect(
|
||||
stripeService.createCheckoutSession({
|
||||
tenantId: 101,
|
||||
plan: 'pro',
|
||||
billingCycle: 'monthly',
|
||||
})
|
||||
).rejects.toThrow(CoolCommException);
|
||||
|
||||
await expect(
|
||||
stripeService.createCheckoutSession({
|
||||
tenantId: 101,
|
||||
plan: 'pro',
|
||||
billingCycle: 'monthly',
|
||||
})
|
||||
).rejects.toThrow(/未配置 Stripe/);
|
||||
|
||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('1.1 有密钥时通过 Stripe API 创建 Pro 月付结算会话,映射 id/url', async () => {
|
||||
process.env.STRIPE_SECRET_KEY = 'sk_test_unit_mock_key';
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: {
|
||||
id: 'cs_test_pro_monthly_abc',
|
||||
url: 'https://checkout.stripe.com/c/pay/cs_test_pro_monthly_abc',
|
||||
},
|
||||
});
|
||||
|
||||
const session = await stripeService.createCheckoutSession({
|
||||
tenantId: 101,
|
||||
plan: 'pro',
|
||||
@@ -68,14 +134,33 @@ describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入
|
||||
expect(session.tenantId).toBe(101);
|
||||
expect(session.plan).toBe('pro');
|
||||
expect(session.billingCycle).toBe('monthly');
|
||||
expect(session.sessionId).toMatch(/^cs_test_[a-zA-Z0-9_-]+$/);
|
||||
expect(session.url).toContain('checkout.stripe.com');
|
||||
expect(session.url).toContain(session.sessionId);
|
||||
expect(session.sessionId).toBe('cs_test_pro_monthly_abc');
|
||||
expect(session.url).toBe(
|
||||
'https://checkout.stripe.com/c/pay/cs_test_pro_monthly_abc'
|
||||
);
|
||||
expect(session.currency).toBe('usd');
|
||||
expect(session.amount).toBe(1900); // $19.00 / 月
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'https://api.stripe.com/v1/checkout/sessions',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer sk_test_unit_mock_key',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('1.2 成功为租户创建 Enterprise 年付结算会话,金额折算符合 SaaS 阶梯定价', async () => {
|
||||
it('1.2 有密钥时创建 Enterprise 年付结算会话,金额折算符合 SaaS 阶梯定价', async () => {
|
||||
process.env.STRIPE_SECRET_KEY = 'sk_test_unit_mock_key';
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: {
|
||||
id: 'cs_test_ent_yearly_xyz',
|
||||
url: 'https://checkout.stripe.com/c/pay/cs_test_ent_yearly_xyz',
|
||||
},
|
||||
});
|
||||
|
||||
const session = await stripeService.createCheckoutSession({
|
||||
tenantId: 202,
|
||||
plan: 'enterprise',
|
||||
@@ -86,10 +171,19 @@ describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入
|
||||
expect(session.plan).toBe('enterprise');
|
||||
expect(session.billingCycle).toBe('yearly');
|
||||
expect(session.amount).toBe(99000); // $990.00 / 年 (优惠2个月)
|
||||
expect(session.sessionId).toBe('cs_test_ent_yearly_xyz');
|
||||
expect(session.url).toContain('checkout.stripe.com');
|
||||
});
|
||||
|
||||
it('1.3 默认周期为 monthly,且兼容独立参数传参 (tenantId, plan, billingCycle)', async () => {
|
||||
it('1.3 有密钥时默认周期为 monthly,且兼容独立参数传参 (tenantId, plan, billingCycle)', async () => {
|
||||
process.env.STRIPE_SECRET_KEY = 'sk_test_unit_mock_key';
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: {
|
||||
id: 'cs_test_default_monthly',
|
||||
url: 'https://checkout.stripe.com/c/pay/cs_test_default_monthly',
|
||||
},
|
||||
});
|
||||
|
||||
const session = await stripeService.createCheckoutSession(303, 'pro');
|
||||
|
||||
expect(session.tenantId).toBe(303);
|
||||
@@ -456,11 +550,22 @@ describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入
|
||||
|
||||
const firstRes = await stripeService.handleWebhookEvent(event);
|
||||
expect(firstRes.action).toBe('upgraded');
|
||||
expect(subscriptions.length).toBe(1);
|
||||
expect(processedStripeEvents.some(e => e.eventId === 'evt_unique_1001')).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
// 重复推送相同事件
|
||||
// 重复推送相同事件:只入账一次
|
||||
const secondRes = await stripeService.handleWebhookEvent(event);
|
||||
expect(secondRes.success).toBe(true);
|
||||
expect(secondRes.action).toBe('idempotent_ignored');
|
||||
expect(subscriptions.length).toBe(1);
|
||||
expect(
|
||||
(
|
||||
stripeService.teamSubscriptionEntity
|
||||
.save as unknown as jest.Mock
|
||||
).mock.calls.length
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('5.2 收到 invoice.payment_failed 扣款失败时流转为 past_due 宽限期', async () => {
|
||||
@@ -494,5 +599,73 @@ describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入
|
||||
const updated = subscriptions.find(s => s.tenantId === 66);
|
||||
expect(updated?.status).toBe('past_due');
|
||||
});
|
||||
|
||||
it('5.3 进程重启后凭持久化记录仍识别已处理 event.id,不重复入账', async () => {
|
||||
const event: StripeWebhookEvent = {
|
||||
id: 'evt_persist_restart_2002',
|
||||
type: 'checkout.session.completed',
|
||||
created: 1700000000,
|
||||
data: {
|
||||
object: {
|
||||
client_reference_id: '2002',
|
||||
metadata: { tenantId: '2002', plan: 'pro' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const firstRes = await stripeService.handleWebhookEvent(event);
|
||||
expect(firstRes.action).toBe('upgraded');
|
||||
expect(subscriptions.length).toBe(1);
|
||||
expect(processedStripeEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ eventId: 'evt_persist_restart_2002' }),
|
||||
])
|
||||
);
|
||||
|
||||
// 模拟进程重启:新 Service 实例清空内存 Set,但共享同一持久化表
|
||||
const restarted = new TeamStripeService();
|
||||
restarted.teamSubscriptionEntity =
|
||||
createMockSubscriptionRepo() as unknown as TeamStripeService['teamSubscriptionEntity'];
|
||||
restarted.teamStripeEventEntity =
|
||||
createMockStripeEventRepo() as unknown as TeamStripeService['teamStripeEventEntity'];
|
||||
restarted.webhookSecret = testWebhookSecret;
|
||||
expect(restarted.processedEventIds.size).toBe(0);
|
||||
|
||||
const secondRes = await restarted.handleWebhookEvent(event);
|
||||
expect(secondRes.action).toBe('idempotent_ignored');
|
||||
expect(subscriptions.length).toBe(1);
|
||||
expect(
|
||||
(restarted.teamSubscriptionEntity.save as unknown as jest.Mock).mock
|
||||
.calls.length
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('5.4 无事件表 repository 时仍可用内存 Set 兜底幂等', async () => {
|
||||
const memoryOnly = new TeamStripeService();
|
||||
memoryOnly.teamSubscriptionEntity =
|
||||
createMockSubscriptionRepo() as unknown as TeamStripeService['teamSubscriptionEntity'];
|
||||
memoryOnly.teamStripeEventEntity = undefined as unknown as TeamStripeService['teamStripeEventEntity'];
|
||||
memoryOnly.webhookSecret = testWebhookSecret;
|
||||
|
||||
const event: StripeWebhookEvent = {
|
||||
id: 'evt_memory_only_55',
|
||||
type: 'checkout.session.completed',
|
||||
created: 1700000000,
|
||||
data: {
|
||||
object: {
|
||||
client_reference_id: '55',
|
||||
metadata: { tenantId: '55', plan: 'pro' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const firstRes = await memoryOnly.handleWebhookEvent(event);
|
||||
expect(firstRes.action).toBe('upgraded');
|
||||
expect(subscriptions.length).toBe(1);
|
||||
|
||||
const secondRes = await memoryOnly.handleWebhookEvent(event);
|
||||
expect(secondRes.action).toBe('idempotent_ignored');
|
||||
expect(subscriptions.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户