const Fastify = require('fastify'); const integrationsRoutes = require('../../../src/routes/integrations'); const { AppError } = require('../../../src/utils/AppError'); const ACTOR_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const OUTBOX_ID = '11111111-1111-4111-8111-111111111111'; function buildOutbox(overrides = {}) { return { id: OUTBOX_ID, event_type: 'erp.journal.export', status: 'failed', payload: { entityType: 'reimbursement_form', entityId: '22222222-2222-4222-8222-222222222222', sourceEventName: 'reimbursement.paid', journalType: 'reimbursement_paid', documentNo: 'BX-20260407-001', token: 'sanitized-value', }, attempts: 2, max_attempts: 5, last_error: 'HTTP 500 | timeout', next_retry_at: new Date('2026-04-07T10:00:00.000Z'), created_at: new Date('2026-04-07T09:00:00.000Z'), processed_at: null, dead_letter_at: null, updated_at: new Date('2026-04-07T09:30:00.000Z'), ...overrides, }; } async function buildApp(options = {}) { const app = Fastify(); const actor = options.actor ?? { id: ACTOR_ID, permissionCodes: ['integration.manage'], }; const findMany = options.findMany ?? vi.fn().mockResolvedValue([buildOutbox()]); const findUnique = options.findUnique ?? vi.fn().mockResolvedValue(buildOutbox()); const update = options.update ?? vi.fn().mockImplementation(async ({ where, data }) => ({ ...buildOutbox({ id: where.id, event_type: "notification.im" }), ...data, updated_at: new Date('2026-04-07T10:30:00.000Z'), })); const count = options.count ?? vi.fn().mockImplementation(async ({ where } = {}) => { if (where?.status === 'pending') return 3; if (where?.status === 'processing') return 1; if (where?.status === 'succeeded') return 8; if (where?.status === 'failed') return 2; if (where?.status === 'dead_letter') return 1; return 1; }); const auditCreate = options.auditCreate ?? vi.fn().mockResolvedValue({}); app.decorate('authenticate', async (request) => { request.employee = actor; }); app.decorate('requirePermission', (permissionCode) => async (request) => { request.employee = actor; const permissionCodes = actor.permissionCodes ?? []; if (!permissionCodes.includes('*') && !permissionCodes.includes(permissionCode)) { throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403 }); } }); app.decorate('hasPermission', (employee, permissionCode) => { const permissionCodes = employee.permissionCodes ?? []; return permissionCodes.includes('*') || permissionCodes.includes(permissionCode); }); app.decorate('config', { erpSyncEnabled: true, erpSyncProvider: 'mock', erpMasterDataEndpoint: 'http://example.test/master-data', erpJournalExportEndpoint: 'http://example.test/journal', erpSyncEndpoint: 'http://example.test/journal', erpMasterDataSyncCron: '0 * * * *', }); app.decorate('prisma', { featureFlag: { findUnique: vi.fn().mockResolvedValue({ id: 'flag-erp', flag_key: 'erp_sync', override_mode: 'force_on', rollout_type: 'global', department_ids: [], }), }, integrationOutbox: { findMany, findUnique, count, update, }, auditLog: { create: auditCreate, }, }); app.setErrorHandler((error, request, reply) => { reply.status(error.statusCode || 500).send({ requestId: request.id, error: { code: error.code || 'SYS_001', message: error.message, details: error.details ?? null, }, }); }); await app.register(integrationsRoutes, { prefix: '/api' }); return { app, findMany, findUnique, count, update, auditCreate }; } describe('integrations outbox routes', () => { it('keeps retired ERP history readable but refuses retry and removed endpoints', async () => { const { app, update, auditCreate } = await buildApp(); const read = await app.inject({ method: 'GET', url: `/api/integrations/outbox/${OUTBOX_ID}` }); expect(read.statusCode).toBe(200); const retry = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry`, payload: {} }); expect(retry.statusCode).toBe(409); expect(retry.json().error.details.reasonCode).toBe('integration.erp_retired'); expect(update).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); const removed = await app.inject({ method: 'POST', url: `/api/integrations/erp/sync/${OUTBOX_ID}`, payload: {} }); expect(removed.statusCode).toBe(404); await app.close(); }); it('lists outbox events with filters and payload summary', async () => { const { app, findMany, count } = await buildApp(); const response = await app.inject({ method: 'GET', url: '/api/integrations/outbox?page=2&pageSize=10&status=failed&event_type=erp.journal.export', }); expect(response.statusCode).toBe(200); expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { status: 'failed', event_type: 'erp.journal.export', }, orderBy: [{ created_at: 'desc' }], skip: 10, take: 10, }), ); expect(count).toHaveBeenCalledWith({ where: { status: 'failed', event_type: 'erp.journal.export', }, }); const payload = response.json(); expect(payload.data.page).toBe(2); expect(payload.data.items[0]).toMatchObject({ id: OUTBOX_ID, eventType: 'erp.journal.export', status: 'failed', payloadSummary: { entityType: 'reimbursement_form', entityId: '22222222-2222-4222-8222-222222222222', sourceEventName: 'reimbursement.paid', journalType: 'reimbursement_paid', exportType: null, identityCode: 'BX-20260407-001', }, }); await app.close(); }); it('returns stats with missing buckets defaulted to zero', async () => { const { app } = await buildApp({ count: vi.fn().mockImplementation(async ({ where } = {}) => { if (where?.status === 'succeeded') return 5; return 0; }), }); const response = await app.inject({ method: 'GET', url: '/api/integrations/outbox/stats', }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ pending: 0, processing: 0, succeeded: 5, failed: 0, deadLetter: 0, }); await app.close(); }); it('returns outbox detail with full payload', async () => { const { app, findUnique } = await buildApp(); const response = await app.inject({ method: 'GET', url: `/api/integrations/outbox/${OUTBOX_ID}`, }); expect(response.statusCode).toBe(200); expect(findUnique).toHaveBeenCalledWith({ where: { id: OUTBOX_ID } }); expect(response.json().data.item.payload).toMatchObject({ documentNo: 'BX-20260407-001', token: 'sanitized-value', }); await app.close(); }); it('retries failed or dead-letter outbox events by resetting status to pending', async () => { const { app, update, auditCreate } = await buildApp({ findUnique: vi.fn().mockResolvedValue(buildOutbox({ event_type: 'notification.im', status: 'dead_letter', dead_letter_at: new Date('2026-04-07T09:40:00.000Z') })), }); const response = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry`, payload: {}, }); expect(response.statusCode).toBe(200); expect(update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: OUTBOX_ID }, data: expect.objectContaining({ status: 'pending', attempts: 0, last_error: null, next_retry_at: expect.any(Date), processed_at: null, dead_letter_at: null, updated_by: ACTOR_ID, }), }), ); expect(auditCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: 'INTEGRATION_OUTBOX_RETRIED', target_type: 'integration_outbox', target_id: OUTBOX_ID, extra: expect.objectContaining({ previousStatus: 'dead_letter', nextStatus: 'pending', eventType: 'notification.im', outboxId: OUTBOX_ID, }), }), }), ); await app.close(); }); it('rejects retry for succeeded items with 409', async () => { const { app, update, auditCreate } = await buildApp({ findUnique: vi.fn().mockResolvedValue(buildOutbox({ status: 'succeeded', last_error: null })), }); const response = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry`, payload: {}, }); expect(response.statusCode).toBe(409); expect(update).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); await app.close(); }); it('blocks users without integration.manage', async () => { const { app } = await buildApp({ actor: { id: ACTOR_ID, permissionCodes: [], }, }); const response = await app.inject({ method: 'GET', url: '/api/integrations/outbox', }); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe('PERM_001'); await app.close(); }); }); describe('shared outbox operation reasons', () => { it.each([['GET', '/integrations/outbox?page=0', 'integration.outbox_query_invalid'], ['GET', '/integrations/outbox/bad', 'integration.outbox_id_invalid'], ['POST', '/integrations/outbox/bad/retry', 'integration.outbox_id_invalid']])('rejects %s %s before reads or mutations', async (method, path, reasonCode) => { const { app, findMany, findUnique, update, auditCreate } = await buildApp(); try { const r = await app.inject({ method, url: `/api${path}` }); expect(r.statusCode).toBe(400); expect(r.json().error.details.reasonCode).toBe(reasonCode); expect(findMany).not.toHaveBeenCalled(); expect(findUnique).not.toHaveBeenCalled(); expect(update).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); it.each(['pending', 'processing', 'succeeded'])('keeps non-retryable notification state %s', async (status) => { const { app, update, auditCreate } = await buildApp({ findUnique: vi.fn().mockResolvedValue(buildOutbox({ event_type: 'notification.im', status })) }); try { const r = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry` }); expect(r.statusCode).toBe(409); expect(r.json().error.details).toEqual({ outbox: 'integration_outbox_retry_status_invalid', status, reasonCode: 'integration.retry_state_invalid' }); expect(update).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); it.each(['failed', 'dead_letter'])('retains notification %s retry as pending without delivery', async (status) => { const before = buildOutbox({ event_type: 'notification.im', status, payload: { channelId: 'synthetic-channel' } }); const update = vi.fn(async ({ data }) => ({ ...before, ...data })); const { app, auditCreate } = await buildApp({ findUnique: vi.fn().mockResolvedValue(before), update }); try { const r = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry` }); expect(r.statusCode).toBe(200); expect(update.mock.calls[0][0].data).toMatchObject({ status: 'pending', attempts: 0, last_error: null }); expect(auditCreate).toHaveBeenCalledOnce(); } finally { await app.close(); } }); });