Files
lindyson-ai/apps/api/tests/notificationOutbox.test.ts
Juno ChowandClaude Fable 5.1 f665082dad chore: 建立产品仓库,完成开源前的敏感信息清理
前端(Vite + React)、API/worker(Fastify + BullMQ)、api-contracts 契约包、
迁移脚本、测试与自托管 docker-compose 示例。

开源准备:运维脚本与机器信息已拆到私有仓库;公司名、备案号、隐私邮箱改读
publicCompliance.local.json(仓库只保留占位模板);站点地址与客服二维码改为
VITE_ 环境变量;fixtures 中的真实用户标识已匿名化;三个 package.json 标注
Apache-2.0。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 02:10:46 +08:00

159 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from 'node:fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
poolQuery: vi.fn(),
sendInvoiceRequestedAdminEmail: vi.fn(),
}));
vi.mock('../src/db/pool.js', () => ({
pool: { query: mocks.poolQuery },
}));
vi.mock('../src/notifications/emailService.js', () => ({
sendInvoiceRequestedAdminEmail: mocks.sendInvoiceRequestedAdminEmail,
}));
import {
processInvoiceNotificationOutbox,
scheduleInvoiceRequestedAdminNotification,
} from '../src/domain/notificationOutbox.js';
const INVOICE_ID = '44444444-4444-4444-8444-444444444444';
const ORDER_ID = '33333333-3333-4333-8333-333333333333';
describe('发票管理员通知 Outbox', () => {
beforeEach(() => {
mocks.poolQuery.mockReset();
mocks.sendInvoiceRequestedAdminEmail.mockReset();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('在业务事务中使用固定事件键幂等写入管理员通知', async () => {
const client = { query: vi.fn().mockResolvedValue(queryResult([])) };
await expect(scheduleInvoiceRequestedAdminNotification(client as never, notificationPayload()))
.resolves.toBe(`invoice-requested-admin:${INVOICE_ID}`);
expect(client.query).toHaveBeenCalledWith(
expect.stringMatching(/INSERT INTO invoice_notification_outbox[\s\S]*ON CONFLICT \(event_key\) DO NOTHING/),
[`invoice-requested-admin:${INVOICE_ID}`, INVOICE_ID, JSON.stringify(notificationPayload())],
);
});
it('投递成功后完成 Outbox,Resend 始终复用固定事件键', async () => {
mocks.poolQuery
.mockResolvedValueOnce(queryResult([outboxRow({ attempts: 1 })]))
.mockResolvedValueOnce(queryResult([]));
mocks.sendInvoiceRequestedAdminEmail.mockResolvedValue({
status: 'sent',
providerMessageId: 'em_invoice_1',
});
await expect(processInvoiceNotificationOutbox(1)).resolves.toEqual({
scanned: 1,
completed: 1,
skipped: 0,
retried: 0,
failed: 0,
});
expect(mocks.sendInvoiceRequestedAdminEmail).toHaveBeenCalledWith(expect.objectContaining({
idempotencyKey: `invoice-requested-admin:${INVOICE_ID}`,
accountEmail: 'account@example.com',
receiveEmail: 'invoice-delivery@example.com',
}));
expect(mocks.poolQuery).toHaveBeenLastCalledWith(
expect.stringMatching(/SET status = 'completed'/),
['outbox-1', null, 'em_invoice_1'],
);
});
it('投递异常不会回滚发票申请,而是释放租约等待重试', async () => {
mocks.poolQuery
.mockResolvedValueOnce(queryResult([outboxRow({ attempts: 1 })]))
.mockResolvedValueOnce(queryResult([]));
mocks.sendInvoiceRequestedAdminEmail.mockRejectedValue(new Error('network down'));
await expect(processInvoiceNotificationOutbox(1)).resolves.toEqual(expect.objectContaining({
scanned: 1,
retried: 1,
failed: 0,
}));
expect(mocks.poolQuery).toHaveBeenLastCalledWith(
expect.stringMatching(/SET status = 'pending'[\s\S]*available_at/),
['outbox-1', 'network down', 300],
);
});
it('第八次仍失败会停止自动重试并记录高优先级告警', async () => {
mocks.poolQuery
.mockResolvedValueOnce(queryResult([outboxRow({ attempts: 8 })]))
.mockResolvedValueOnce(queryResult([]));
mocks.sendInvoiceRequestedAdminEmail.mockResolvedValue({ status: 'failed', error: 'permanent error' });
await expect(processInvoiceNotificationOutbox(1)).resolves.toEqual(expect.objectContaining({
scanned: 1,
retried: 0,
failed: 1,
}));
expect(mocks.poolQuery).toHaveBeenLastCalledWith(
expect.stringMatching(/SET status = 'failed'/),
['outbox-1', 'permanent error'],
);
expect(console.error).toHaveBeenCalledWith(
'高优先级:发票申请管理员邮件未送达',
expect.objectContaining({ invoiceId: INVOICE_ID, attempts: 8 }),
);
});
it('迁移提供唯一事件键、租约与待处理索引', () => {
const migration = readFileSync(
new URL('../migrations/031_invoice_notification_outbox.sql', import.meta.url),
'utf8',
);
expect(migration).toContain('event_key text NOT NULL UNIQUE');
expect(migration).toContain('invoice_id uuid NOT NULL UNIQUE');
expect(migration).toContain('payload jsonb NOT NULL');
expect(migration).toContain('lease_until timestamptz');
expect(migration).toContain('idx_invoice_notification_outbox_pending');
});
});
function outboxRow(overrides: Record<string, unknown> = {}) {
return {
id: 'outbox-1',
event_key: `invoice-requested-admin:${INVOICE_ID}`,
invoice_id: INVOICE_ID,
attempts: 1,
payload: notificationPayload(),
...overrides,
};
}
function notificationPayload() {
return {
invoiceId: INVOICE_ID,
orderId: ORDER_ID,
userId: 'user-1',
userName: '测试用户',
accountEmail: 'account@example.com',
planName: '专业版',
amount: 2900,
invoiceTitle: '测试公司',
taxId: '91310000TEST',
receiveEmail: 'invoice-delivery@example.com',
note: '电子发票',
};
}
function queryResult(rows: unknown[]) {
return { rowCount: rows.length, rows };
}