feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户