Files
lindyson-ai/apps/api/tests/paymentNotificationOutbox.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

114 lines
3.4 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
poolQuery: vi.fn(),
sendPaymentSuccessEmail: vi.fn(),
}));
vi.mock('../src/db/pool.js', () => ({
pool: { query: mocks.poolQuery },
}));
vi.mock('../src/notifications/emailService.js', () => ({
sendInvoiceRequestedAdminEmail: vi.fn(),
sendPaymentSuccessEmail: mocks.sendPaymentSuccessEmail,
}));
import { processPaymentNotificationOutbox } from '../src/domain/notificationOutbox.js';
describe('支付成功通知 Outbox', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('发送成功只允许持有 claim token 的 worker 完成记录', async () => {
mocks.poolQuery
.mockResolvedValueOnce(result([outboxRow()]))
.mockResolvedValueOnce({ rows: [], rowCount: 1 });
mocks.sendPaymentSuccessEmail.mockResolvedValue({ status: 'sent', providerMessageId: 'email-1' });
await expect(processPaymentNotificationOutbox(1)).resolves.toEqual({
scanned: 1,
completed: 1,
skipped: 0,
retried: 0,
failed: 0,
});
expect(mocks.sendPaymentSuccessEmail).toHaveBeenCalledWith(expect.objectContaining({
idempotencyKey: 'payment-succeeded:11111111-1111-4111-8111-111111111111',
}));
expect(mocks.poolQuery).toHaveBeenNthCalledWith(
2,
expect.stringMatching(/status = 'processing' AND lease_token = \$2::uuid/),
[
'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
null,
'email-1',
],
);
});
it('暂时失败以同一 claim token CAS 退回 pending 并安排退避', async () => {
mocks.poolQuery
.mockResolvedValueOnce(result([outboxRow({ attempts: 2 })]))
.mockResolvedValueOnce({ rows: [], rowCount: 1 });
mocks.sendPaymentSuccessEmail.mockResolvedValue({ status: 'failed', error: 'network' });
await expect(processPaymentNotificationOutbox(1)).resolves.toEqual({
scanned: 1,
completed: 0,
skipped: 0,
retried: 1,
failed: 0,
});
expect(mocks.poolQuery).toHaveBeenNthCalledWith(
2,
expect.stringMatching(/status = 'processing' AND lease_token = \$2::uuid/),
[
'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
'pending',
'network',
expect.any(Number),
],
);
});
it('旧 worker 的 CAS 更新为零行时不得虚报完成', async () => {
mocks.poolQuery
.mockResolvedValueOnce(result([outboxRow()]))
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
mocks.sendPaymentSuccessEmail.mockResolvedValue({ status: 'sent', providerMessageId: 'email-1' });
await expect(processPaymentNotificationOutbox(1)).resolves.toEqual({
scanned: 1,
completed: 0,
skipped: 0,
retried: 0,
failed: 0,
});
});
});
function outboxRow(overrides: Record<string, unknown> = {}) {
return {
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
event_key: 'payment-succeeded:11111111-1111-4111-8111-111111111111',
attempts: 1,
lease_token: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
payload: {
userId: '22222222-2222-4222-8222-222222222222',
email: 'user@example.com',
orderId: '11111111-1111-4111-8111-111111111111',
planName: '专业版',
amount: 22800,
credits: 10000,
},
...overrides,
};
}
function result<T>(rows: T[]) {
return { rows, rowCount: rows.length };
}