const Fastify = require('fastify'); const integrationsRoutes = require('../../../src/routes/integrations'); const { AppError } = require('../../../src/utils/AppError'); const ACTOR_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; function buildBinding(overrides = {}) { return { id: '11111111-1111-4111-8111-111111111111', provider: 'erp', entity_type: 'purchase_order_po', local_id: '22222222-2222-4222-8222-222222222222', external_id: 'ERP-PO-001', external_code: 'PO-001', last_synced_at: new Date('2026-04-07T10:00:00.000Z'), created_at: new Date('2026-04-07T09:00: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([ buildBinding(), buildBinding({ id: '33333333-3333-4333-8333-333333333333', entity_type: 'reimbursement_journal', local_id: '44444444-4444-4444-8444-444444444444', external_id: 'ERP-BX-001', external_code: 'BX-001', }), ]); const count = options.count ?? vi.fn().mockResolvedValue(2); const archiveFindMany = options.archiveFindMany ?? vi.fn().mockResolvedValue([ { id: '22222222-2222-4222-8222-222222222222', purchase_request_id: '55555555-5555-4555-8555-555555555555', }, ]); 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: [], }), }, integrationBinding: { findMany, count, }, purchaseOrderArchive: { findMany: archiveFindMany, }, auditLog: { create: vi.fn().mockResolvedValue({}), }, }); 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, count, archiveFindMany }; } describe('integrations binding routes', () => { it('lists bindings with filters and route paths', async () => { const { app, findMany, count, archiveFindMany } = await buildApp(); const response = await app.inject({ method: 'GET', url: '/api/integrations/bindings?page=2&pageSize=10&provider=erp&entity_type=purchase_order_po', }); expect(response.statusCode).toBe(200); expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { provider: 'erp', entity_type: 'purchase_order_po', }, orderBy: [{ last_synced_at: { sort: 'desc', nulls: 'last' } }, { created_at: 'desc' }], skip: 10, take: 10, }), ); expect(count).toHaveBeenCalledWith({ where: { provider: 'erp', entity_type: 'purchase_order_po', }, }); expect(archiveFindMany).toHaveBeenCalledWith({ where: { id: { in: ['22222222-2222-4222-8222-222222222222'], }, }, select: { id: true, purchase_request_id: true, }, }); const payload = response.json(); expect(payload.data.items[0]).toMatchObject({ entityType: 'purchase_order_po', localId: '22222222-2222-4222-8222-222222222222', routePath: '/purchase-requests?id=55555555-5555-4555-8555-555555555555', }); expect(payload.data.items[1]).toMatchObject({ entityType: 'reimbursement_journal', routePath: '/reimbursements/44444444-4444-4444-8444-444444444444', }); await app.close(); }); it('returns null routePath for unsupported entity types', async () => { const { app } = await buildApp({ findMany: vi.fn().mockResolvedValue([ buildBinding({ entity_type: 'travel_payable_summary', local_id: '66666666-6666-4666-8666-666666666666', }), ]), count: vi.fn().mockResolvedValue(1), archiveFindMany: vi.fn().mockResolvedValue([]), }); const response = await app.inject({ method: 'GET', url: '/api/integrations/bindings', }); expect(response.statusCode).toBe(200); expect(response.json().data.items[0].routePath).toBe(null); 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/bindings', }); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe('PERM_001'); await app.close(); }); });