const Fastify = require('fastify'); const { AppError } = require('../../../src/utils/AppError'); const travelReconciliationRoutes = require('../../../src/routes/travelReconciliation'); function buildTravelRequest(overrides = {}) { return { id: '11111111-1111-4111-8111-111111111111', request_no: 'CL-20260311-0001', ...overrides, }; } function buildOrder(overrides = {}) { return { id: '22222222-2222-4222-8222-222222222222', source: 'ctrip', external_order_no: 'EXT-001', linked_reimbursement_id: null, travel_request_id: '11111111-1111-4111-8111-111111111111', amount: '100.00', currency: 'CNY', status: 'paid', payment_date: new Date('2026-03-10T00:00:00.000Z'), paid_at: new Date('2026-03-10T08:00:00.000Z'), created_at: new Date('2026-03-10T08:00:00.000Z'), updated_at: new Date('2026-03-10T08:00:00.000Z'), travel_request: { id: '11111111-1111-4111-8111-111111111111', request_no: 'CL-20260311-0001', legal_entity_id: '44444444-4444-4444-8444-444444444444', cost_center_id: '55555555-5555-4555-8555-555555555555', }, ...overrides, }; } function buildTransaction(overrides = {}) { return { id: '33333333-3333-4333-8333-333333333333', source: 'corp-card', external_txn_id: 'TXN-001', travel_request_id: '11111111-1111-4111-8111-111111111111', travel_order_id: '22222222-2222-4222-8222-222222222222', amount: '100.00', currency: 'CNY', txn_type: 'charge', status: 'settled', occurred_at: new Date('2026-03-10T08:30:00.000Z'), created_at: new Date('2026-03-10T08:30:00.000Z'), travel_request: { id: '11111111-1111-4111-8111-111111111111', request_no: 'CL-20260311-0001', }, travel_order: { id: '22222222-2222-4222-8222-222222222222', external_order_no: 'EXT-001', linked_reimbursement_id: null, status: 'paid', }, ...overrides, }; } function buildPrisma({ orders = [], transactions = [], invoices = [], featureFlag = { id: 'flag-1', flag_key: 'erp_sync', override_mode: 'force_on', rollout_type: 'global', department_ids: [], note: '', version: 1, created_at: new Date('2026-03-11T00:00:00.000Z'), updated_at: new Date('2026-03-11T00:00:00.000Z'), }, } = {}) { const state = { legalEntities: [{ id: '44444444-4444-4444-8444-444444444444', code: 'HY', name: '海音达' }], costCenters: [{ id: '55555555-5555-4555-8555-555555555555', code: 'CC001', name: '销售中心', legal_entity_id: '44444444-4444-4444-8444-444444444444' }], travelRequests: [buildTravelRequest()], orders: orders.map((item) => ({ ...item })), transactions: transactions.map((item) => ({ ...item })), invoices: invoices.map((item) => ({ ...item })), batches: [], items: [], integrationOutboxes: [], auditLogs: [], sequenceMap: {}, }; let batchCounter = 1; let itemCounter = 1; function materializeTravelRequest(id) { const request = state.travelRequests.find((item) => item.id === id); return request ? { id: request.id, request_no: request.request_no } : null; } function materializeOrder(id) { const order = state.orders.find((item) => item.id === id); return order ? { id: order.id, external_order_no: order.external_order_no, status: order.status, } : null; } function materializeTransaction(id) { const transaction = state.transactions.find((item) => item.id === id); return transaction ? { id: transaction.id, external_txn_id: transaction.external_txn_id, status: transaction.status, txn_type: transaction.txn_type, } : null; } function materializeItem(item) { const batch = state.batches.find((row) => row.id === item.travel_reconciliation_batch_id); return { ...item, batch: batch ? { id: batch.id, status: batch.status, summary_json: batch.summary_json, } : null, travel_request: materializeTravelRequest(item.travel_request_id), travel_order: materializeOrder(item.travel_order_id), card_transaction: materializeTransaction(item.card_transaction_id), }; } function materializeBatch(batch) { if (!batch) { return null; } return { ...batch, legal_entity: state.legalEntities.find((item) => item.id === batch.legal_entity_id) ?? null, cost_center: state.costCenters.find((item) => item.id === batch.cost_center_id) ?? null, confirmed_by_employee: batch.confirmed_by ? { id: batch.confirmed_by, employee_no: 'E001', name: '财务一号' } : null, items: state.items .filter((item) => item.travel_reconciliation_batch_id === batch.id) .map(materializeItem), }; } const prisma = { legalEntity: { findUnique: vi.fn().mockImplementation(async ({ where }) => { return state.legalEntities.find((item) => item.id === where.id) ?? null; }), }, costCenter: { findUnique: vi.fn().mockImplementation(async ({ where }) => { return state.costCenters.find((item) => item.id === where.id) ?? null; }), }, travelOrder: { findMany: vi.fn().mockImplementation(async () => state.orders.map((item) => ({ ...item }))), }, cardTransaction: { findMany: vi.fn().mockImplementation(async () => state.transactions.map((item) => ({ ...item }))), }, invoicePool: { findMany: vi.fn().mockImplementation(async ({ where }) => { const ids = new Set(where?.travel_order_id?.in ?? []); return state.invoices.filter((item) => ids.has(item.travel_order_id)).map((item) => ({ ...item })); }), }, documentCategory: { findUnique: vi.fn().mockImplementation(async ({ where }) => { if (where.code === 'TR_RECON') { return { code: 'TR_RECON', entity_type: 'travel_reconciliation_batch', sequence_prefix: 'TRB', number_length: 4, reset_period: 'yearly', enabled: true, archived_at: null, }; } return null; }), findFirst: vi.fn().mockResolvedValue(null), }, documentSequence: { findUnique: vi.fn().mockImplementation(async ({ where }) => state.sequenceMap[where.document_type] ?? null), upsert: vi.fn().mockImplementation(async ({ where, create, update }) => { const current = state.sequenceMap[where.document_type]; if (current) { const next = { ...current, prefix: update.prefix, date_format: update.date_format, separator: update.separator, number_length: update.number_length, reset_period: update.reset_period, current_number: current.current_number + 1, updated_by: update.updated_by, updated_at: new Date('2026-03-11T09:00:00.000Z'), }; state.sequenceMap[where.document_type] = next; return next; } const created = { document_type: create.document_type, prefix: create.prefix, date_format: create.date_format, separator: create.separator, number_length: create.number_length, reset_period: create.reset_period, current_number: create.current_number, last_reset_date: null, updated_at: new Date('2026-03-11T09:00:00.000Z'), updated_by: create.updated_by, }; state.sequenceMap[where.document_type] = created; return created; }), }, travelReconciliationBatch: { create: vi.fn().mockImplementation(async ({ data }) => { const batch = { id: `66666666-6666-4666-8666-${String(batchCounter++).padStart(12, '0')}`, batch_no: data.batch_no, legal_entity_id: data.legal_entity_id, cost_center_id: data.cost_center_id ?? null, period_start: data.period_start, period_end: data.period_end, status: data.status, summary_json: data.summary_json, confirmed_at: null, confirmed_by: null, created_at: new Date('2026-03-11T09:00:00.000Z'), created_by: data.created_by ?? null, updated_at: new Date('2026-03-11T09:00:00.000Z'), updated_by: data.updated_by ?? null, }; state.batches.push(batch); return { id: batch.id }; }), findUnique: vi.fn().mockImplementation(async ({ where }) => { const batch = state.batches.find((item) => item.id === where.id) ?? null; return materializeBatch(batch); }), findMany: vi.fn().mockImplementation(async ({ where }) => { return state.batches .filter((item) => { if (where?.status && item.status !== where.status) return false; if (where?.legal_entity_id && item.legal_entity_id !== where.legal_entity_id) return false; if (where?.cost_center_id && item.cost_center_id !== where.cost_center_id) return false; return true; }) .map(materializeBatch); }), count: vi.fn().mockImplementation(async ({ where }) => { return state.batches.filter((item) => { if (where?.status && item.status !== where.status) return false; if (where?.legal_entity_id && item.legal_entity_id !== where.legal_entity_id) return false; if (where?.cost_center_id && item.cost_center_id !== where.cost_center_id) return false; return true; }).length; }), update: vi.fn().mockImplementation(async ({ where, data }) => { const index = state.batches.findIndex((item) => item.id === where.id); state.batches[index] = { ...state.batches[index], ...data, updated_at: new Date('2026-03-11T10:00:00.000Z'), }; return materializeBatch(state.batches[index]); }), }, travelReconciliationItem: { createMany: vi.fn().mockImplementation(async ({ data }) => { data.forEach((row) => { state.items.push({ id: `77777777-7777-4777-8777-${String(itemCounter++).padStart(12, '0')}`, ...row, created_at: new Date('2026-03-11T09:00:00.000Z'), updated_at: new Date('2026-03-11T09:00:00.000Z'), }); }); return { count: data.length }; }), findUnique: vi.fn().mockImplementation(async ({ where }) => { const item = state.items.find((row) => row.id === where.id) ?? null; return item ? materializeItem(item) : null; }), update: vi.fn().mockImplementation(async ({ where, data }) => { const index = state.items.findIndex((item) => item.id === where.id); state.items[index] = { ...state.items[index], ...data, updated_at: new Date('2026-03-11T10:00:00.000Z'), }; return materializeItem(state.items[index]); }), }, integrationOutbox: { findUnique: vi.fn().mockImplementation(async ({ where }) => { const key = where?.event_type_dedupe_key; if (!key) { return null; } return ( state.integrationOutboxes.find( (item) => item.event_type === key.event_type && item.dedupe_key === key.dedupe_key, ) ?? null ); }), create: vi.fn().mockImplementation(async ({ data }) => { const row = { id: `outbox-${state.integrationOutboxes.length + 1}`, event_type: data.event_type, status: data.status, payload: data.payload, dedupe_key: data.dedupe_key ?? null, max_attempts: data.max_attempts, created_by: data.created_by ?? null, updated_by: data.updated_by ?? null, attempts: 0, last_error: null, processed_at: null, dead_letter_at: null, next_retry_at: null, created_at: new Date('2026-03-11T10:00:00.000Z'), updated_at: new Date('2026-03-11T10:00:00.000Z'), }; state.integrationOutboxes.push(row); return { id: row.id, status: row.status }; }), update: vi.fn().mockImplementation(async ({ where, data }) => { const index = state.integrationOutboxes.findIndex((item) => item.id === where.id); state.integrationOutboxes[index] = { ...state.integrationOutboxes[index], ...data, updated_at: new Date('2026-03-11T10:05:00.000Z'), }; return { id: state.integrationOutboxes[index].id, status: state.integrationOutboxes[index].status }; }), }, auditLog: { create: vi.fn().mockImplementation(async ({ data }) => { state.auditLogs.push(data); return { id: `audit-${state.auditLogs.length}` }; }), }, featureFlag: { findUnique: vi.fn().mockResolvedValue(featureFlag), }, $queryRaw: vi.fn().mockImplementation(async (strings, id) => { const sql = Array.isArray(strings) ? strings.join('?') : String(strings); if (sql.includes('FROM "travel_reconciliation_batches"')) { return state.batches.some((item) => item.id === id) ? [{ id }] : []; } if (sql.includes('FROM "travel_reconciliation_items"')) { return state.items.some((item) => item.id === id) ? [{ id }] : []; } return []; }), $transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)), }; return { prisma, state }; } async function buildApp({ prisma, permissionCodes = ['travel.payment.manage'], config = {} }) { const app = Fastify(); app.decorate('prisma', prisma); app.decorate('config', { appTimezone: 'Asia/Shanghai', erpSyncEnabled: true, integrationOutboxMaxAttempts: 5, ...config, }); app.decorate('authenticate', async (request) => { if (!request.headers.authorization) { throw new AppError({ code: 'AUTH_001', message: '未登录', statusCode: 401 }); } request.employee = { id: 'actor-1', department_id: 'dept-1', permissionCodes }; }); app.decorate('hasPermission', (employee, permissionCode) => { return (employee?.permissionCodes ?? []).includes('*') || (employee?.permissionCodes ?? []).includes(permissionCode); }); app.decorate('requirePermission', (permissionCode) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permissionCode)) { throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403 }); } }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ requestId: request.id, error: { code: error.code, message: error.message, details: error.details ?? {}, }, }); } return reply.status(500).send({ requestId: request.id, error: { code: 'SYS_001', message: '服务器内部错误', details: {} }, }); }); await app.register(travelReconciliationRoutes, { prefix: '/api' }); return app; } describe('travelReconciliation routes', () => { it('creates batches and supports list/detail queries', async () => { const { prisma } = buildPrisma({ orders: [buildOrder()], transactions: [buildTransaction()], invoices: [{ id: 'inv-1', travel_order_id: '22222222-2222-4222-8222-222222222222', total_amount: '100.00' }], }); const app = await buildApp({ prisma }); const createResponse = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31', }, }); expect(createResponse.statusCode).toBe(200); const created = createResponse.json().data; expect(created).toEqual( expect.objectContaining({ batchNo: expect.stringMatching(/^TRB-\d{8}-\d{4}$/), status: 'draft', permissions: expect.objectContaining({ canManagePayments: true, }), }), ); expect(created.summary).toEqual( expect.objectContaining({ totalOrders: 1, totalTransactions: 1, matchedItems: 1, pendingItems: 0, }), ); const listResponse = await app.inject({ method: 'GET', url: '/api/travel/reconciliation/batches?status=draft', headers: { authorization: 'Bearer test' }, }); expect(listResponse.statusCode).toBe(200); expect(listResponse.json().data.total).toBe(1); const detailResponse = await app.inject({ method: 'GET', url: `/api/travel/reconciliation/batches/${created.id}`, headers: { authorization: 'Bearer test' }, }); expect(detailResponse.statusCode).toBe(200); expect(detailResponse.json().data.items).toHaveLength(1); await app.close(); }); it('confirms and reopens anomaly items inside draft batch', async () => { const { prisma } = buildPrisma({ orders: [buildOrder()], transactions: [], }); const app = await buildApp({ prisma }); const createResponse = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31', }, }); const created = createResponse.json().data; const item = created.items.find((row) => row.matchStatus !== 'matched'); const confirmResponse = await app.inject({ method: 'POST', url: `/api/travel/reconciliation/items/${item.id}/confirm`, headers: { authorization: 'Bearer test' }, payload: { note: '金额差异已人工确认' }, }); expect(confirmResponse.statusCode).toBe(200); expect(confirmResponse.json().data.summary.pendingItems).toBe(0); expect(confirmResponse.json().data.item.resolutionStatus).toBe('confirmed'); expect(confirmResponse.json().data.item.confirmNote).toBe('金额差异已人工确认'); const reopenResponse = await app.inject({ method: 'POST', url: `/api/travel/reconciliation/items/${item.id}/reopen`, headers: { authorization: 'Bearer test' }, payload: {}, }); expect(reopenResponse.statusCode).toBe(200); expect(reopenResponse.json().data.summary.pendingItems).toBe(1); expect(reopenResponse.json().data.item.resolutionStatus).toBe('pending'); await app.close(); }); it('rejects finalize when pending anomaly items remain', async () => { const { prisma } = buildPrisma({ orders: [buildOrder()], transactions: [], }); const app = await buildApp({ prisma }); const createResponse = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31', }, }); const batchId = createResponse.json().data.id; const finalizeResponse = await app.inject({ method: 'POST', url: `/api/travel/reconciliation/batches/${batchId}/finalize`, headers: { authorization: 'Bearer test' }, payload: {}, }); expect(finalizeResponse.statusCode).toBe(409); expect(finalizeResponse.json().error.details.batch).toBe('reconciliation_batch_has_pending_items'); await app.close(); }); it('finalizes confirmed batch without retired ERP side effects', async () => { const { prisma, state } = buildPrisma({ orders: [buildOrder()], transactions: [buildTransaction()], }); const app = await buildApp({ prisma, config: { erpSyncEnabled: true } }); const createResponse = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31', }, }); const batchId = createResponse.json().data.id; const finalizeResponse = await app.inject({ method: 'POST', url: `/api/travel/reconciliation/batches/${batchId}/finalize`, headers: { authorization: 'Bearer test' }, payload: {}, }); expect(finalizeResponse.statusCode).toBe(200); expect(finalizeResponse.json().data).toEqual( expect.objectContaining({ batchId, status: 'reconciled', }), ); expect(state.integrationOutboxes).toHaveLength(0); await app.close(); }); it('returns 409 for empty reconciliation scope', async () => { const { prisma } = buildPrisma({ orders: [], transactions: [] }); const app = await buildApp({ prisma }); const response = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31', }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.batch).toBe('travel_reconciliation_empty_scope'); await app.close(); }); it('blocks reads without travel payment or integration management permission', async () => { const { prisma } = buildPrisma({ orders: [buildOrder()], transactions: [buildTransaction()], }); const app = await buildApp({ prisma, permissionCodes: ['travel.view_own'] }); const response = await app.inject({ method: 'GET', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, }); expect(response.statusCode).toBe(403); await app.close(); }); }); describe('travel reconciliation calendar input', () => { it.each(['POST', 'GET'].flatMap((method) => ['periodStart', 'periodEnd'].map((field) => [method, field])))('rejects %s invalid %s before queries and writes', async (method, field) => { const { prisma, state } = buildPrisma(); const app = await buildApp({ prisma }); try { const input = { legalEntityId: '44444444-4444-4444-8444-444444444444', periodStart: '2026-04-01', periodEnd: '2026-04-30', [field]: '2026-04-31' }; const response = await app.inject({ method, url: '/api/travel/reconciliation/batches' + (method === 'GET' ? `?${new URLSearchParams(input)}` : ''), headers: { authorization: 'Bearer test' }, ...(method === 'POST' ? { payload: input } : {}) }); expect(response.statusCode).toBe(400); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.travelReconciliationBatch.findMany).not.toHaveBeenCalled(); expect(state.batches).toEqual([]); expect(state.auditLogs).toEqual([]); expect(state.integrationOutboxes).toEqual([]); } finally { await app.close(); } }); it('keeps leap-day/month-end, optional filters and reversed-period semantics', async () => { const { prisma } = buildPrisma({ orders: [buildOrder({ payment_date: new Date('2024-03-10T00:00:00.000Z') })], transactions: [buildTransaction({ occurred_at: new Date('2024-03-10T08:30:00.000Z') })] }); const app = await buildApp({ prisma }); try { const base = { legalEntityId: '44444444-4444-4444-8444-444444444444', periodStart: '2024-02-29', periodEnd: '2024-03-31' }; const created = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: base }); expect(created.statusCode).toBe(200); const listed = await app.inject({ method: 'GET', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' } }); expect(listed.statusCode).toBe(200); prisma.$transaction.mockClear(); const reversed = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { ...base, periodStart: '2024-04-30' } }); expect(reversed.statusCode).toBe(409); expect(reversed.json().error.details.period).toBe('period_start_after_period_end'); expect(prisma.$transaction).not.toHaveBeenCalled(); } finally { await app.close(); } }); }); const errorApps = []; afterEach(async () => { await Promise.all(errorApps.splice(0).map((app) => app.close())); }); const ERROR_ID = '66666666-6666-4666-8666-666666666666'; const ERROR_SCOPE = { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31' }; async function errorContext(options) { const context = buildPrisma(options); const app = await buildApp({ prisma: context.prisma }); errorApps.push(app); return { ...context, app }; } function errorRequest(method, path, payload) { return { method, url: `/api/travel/reconciliation${path}`, headers: { authorization: 'Bearer synthetic' }, ...(payload ? { payload } : {}) }; } function expectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error.details).toEqual({ ...details, reasonCode }); expect(response.json().error.code).toBe('VALID_001'); } function noMutations(prisma) { for (const model of [prisma.travelReconciliationBatch, prisma.travelReconciliationItem, prisma.integrationOutbox, prisma.auditLog]) for (const method of ['create', 'createMany', 'update', 'upsert']) if (model?.[method]) expect(model[method]).not.toHaveBeenCalled(); } it.each([ ['GET', '/batches?page=0', undefined, 'query_invalid'], ['POST', '/batches', {}, 'create_input_invalid'], ['POST', `/items/${ERROR_ID}/confirm`, { note: 'x'.repeat(501) }, 'confirm_input_invalid'], ])('explains %s %s input before transaction or queries', async (method, path, payload, reason) => { const { app, prisma } = await errorContext(); const response = await app.inject(errorRequest(method, path, payload)); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: `travel_reconciliation.${reason}`, fieldErrors: expect.any(Object) }); noMutations(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); }); it.each([['GET', '/batches/bad', 'batchId'], ['POST', '/batches/bad/finalize', 'batchId'], ['POST', '/items/bad/confirm', 'itemId'], ['POST', '/items/bad/reopen', 'itemId']])('explains %s %s invalid id with the original 400', async (method, path, key) => { const { app, prisma } = await errorContext(); expectError(await app.inject(errorRequest(method, path, {})), 400, `travel_reconciliation.${key === 'batchId' ? 'batch' : 'item'}_id_invalid`, { [key]: 'invalid_uuid' }); noMutations(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); }); it.each(['period', 'company', 'center', 'mismatch', 'empty'])('preserves batch creation %s refusal as 409', async (state) => { const { app, prisma } = await errorContext(); const payload = { ...ERROR_SCOPE }; if (state === 'period') payload.periodStart = '2026-04-01'; if (state === 'company') prisma.legalEntity.findUnique.mockResolvedValue(null); if (state === 'center') prisma.costCenter.findUnique.mockResolvedValue(null); if (state === 'mismatch') prisma.costCenter.findUnique.mockResolvedValue({ id: ERROR_SCOPE.costCenterId, legal_entity_id: ERROR_ID }); const [reason, details] = { period: ['period_reversed', { period: 'period_start_after_period_end' }], company: ['company_not_found', { legalEntityId: 'not_found' }], center: ['cost_center_not_found', { costCenterId: 'not_found' }], mismatch: ['cost_center_company_mismatch', { costCenterId: 'legal_entity_mismatch' }], empty: ['empty_scope', { batch: 'travel_reconciliation_empty_scope' }] }[state]; expectError(await app.inject(errorRequest('POST', '/batches', payload)), 409, `travel_reconciliation.${reason}`, details); noMutations(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); }); async function createdBatch(matched = false) { const context = await errorContext({ orders: [buildOrder()], transactions: matched ? [buildTransaction()] : [] }); const response = await context.app.inject(errorRequest('POST', '/batches', ERROR_SCOPE)); expect(response.statusCode).toBe(200); // Preserve the synthetic database contents while clearing prior creation call history. for (const model of Object.values(context.prisma)) for (const method of Object.values(model ?? {})) if (vi.isMockFunction(method)) method.mockClear(); return { ...context, batch: context.state.batches[0], item: context.state.items[0] }; } it.each(['confirm', 'reopen'])('preserves non-draft batch guard on %s', async (action) => { const { app, prisma, state, batch, item } = await createdBatch(); batch.status = 'reconciled'; const before = JSON.stringify(state); expectError(await app.inject(errorRequest('POST', `/items/${item.id}/${action}`, {})), 409, 'travel_reconciliation.batch_not_editable', { batch: 'reconciliation_batch_not_editable' }); noMutations(prisma); expect(JSON.stringify(state)).toBe(before); }); it.each(['already-confirmed', 'not-confirmed', 'matched', 'pending-items'])('preserves reconciliation %s service guard through actual route', async (condition) => { const { app, prisma, state, batch, item } = await createdBatch(condition === 'matched'); if (condition === 'already-confirmed') item.resolution_status = 'confirmed'; const [path, reason, details] = { 'already-confirmed': [`/items/${item.id}/confirm`, 'item_not_pending', { item: 'reconciliation_item_not_pending' }], 'not-confirmed': [`/items/${item.id}/reopen`, 'item_not_confirmed', { item: 'reconciliation_item_not_confirmed' }], matched: [`/items/${item.id}/reopen`, 'matched_item_not_reopenable', { item: 'matched_item_not_reopenable' }], 'pending-items': [`/batches/${batch.id}/finalize`, 'batch_has_pending_items', { batch: 'reconciliation_batch_has_pending_items' }], }[condition]; const before = JSON.stringify(state); expectError(await app.inject(errorRequest('POST', path, {})), 409, `travel_reconciliation.${reason}`, details); noMutations(prisma); expect(JSON.stringify(state)).toBe(before); });