const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const { FEATURE_FLAG_KEYS } = require("../../../src/services/featureFlags"); const { registerInvoiceVerificationRoutes } = require("../../../src/routes/invoicePool/verification"); const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; const RECORD_ID = "22222222-2222-4222-8222-222222222222"; const RETRY_RECORD_ID = "33333333-3333-4333-8333-333333333333"; const EMPLOYEE_ID = "44444444-4444-4444-8444-444444444444"; const DEPARTMENT_ID = "dept-finance"; function pickSelect(source, select) { if (!select) { return { ...source }; } return Object.fromEntries( Object.entries(select) .filter(([, enabled]) => Boolean(enabled)) .map(([key]) => [key, source[key]]), ); } function buildFlagRecord({ overrideMode = "force_on", rolloutType = "global", departmentIds = [] } = {}) { const now = new Date("2026-05-28T00:00:00.000Z"); return { id: "flag-invoice-verification", flag_key: FEATURE_FLAG_KEYS.invoiceVerification, override_mode: overrideMode, rollout_type: rolloutType, department_ids: departmentIds, note: "unit-test", version: 1, created_at: now, created_by: null, updated_at: now, updated_by: null, }; } function buildInvoice(overrides = {}) { return { id: INVOICE_ID, status: "available", invoice_code: "0440026", invoice_number: "00012890", total_amount: "1060.00", remarks: "route test", owner_employee_id: EMPLOYEE_ID, uploaded_by_employee_id: EMPLOYEE_ID, owner_employee: { id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, }, linked_reimbursement: null, ...overrides, }; } function buildVerificationRecord(overrides = {}) { return { id: RECORD_ID, invoice_id: INVOICE_ID, provider: "mock", status: "failed", result_code: "invoice_mismatch", result_message: "invoice metadata mismatch", error_code: null, error_message: null, duration_ms: 120, requested_by: EMPLOYEE_ID, requested_at: new Date("2026-05-28T08:00:00.000Z"), completed_at: new Date("2026-05-28T08:00:10.000Z"), retry_of_id: null, attempts: 1, ...overrides, }; } function buildQueryRawHandler(state) { return vi.fn(async (strings, ...values) => { const sql = Array.isArray(strings) ? strings.join("?") : String(strings); const targetId = values[0] ? String(values[0]) : null; if (sql.includes('FROM "invoice_pool"')) { if (!state.invoice || (targetId && state.invoice.id !== targetId)) { return []; } return [{ id: state.invoice.id }]; } return []; }); } function buildPrisma(options = {}) { const state = { invoice: options.invoice === null ? null : buildInvoice(options.invoice), latestRecord: options.latestRecord ?? null, records: options.records ?? [], flagRecord: options.flagRecord ?? buildFlagRecord(), }; const tx = { $queryRaw: buildQueryRawHandler(state), featureFlag: { findUnique: vi.fn(async () => state.flagRecord), }, invoicePool: { findUnique: vi.fn(async ({ where, select }) => { if (!state.invoice || where.id !== state.invoice.id) { return null; } return pickSelect(state.invoice, select); }), }, invoiceVerificationRecord: { findFirst: vi.fn(async () => state.latestRecord), findMany: vi.fn(async () => state.records), create: vi.fn(async ({ data }) => { const created = { id: data.retry_of_id ? RETRY_RECORD_ID : RECORD_ID, ...data, created_at: new Date("2026-05-28T08:05:00.000Z"), }; state.latestRecord = created; state.records = [created, ...state.records]; return pickSelect(created, { id: true }); }), }, auditLog: { create: vi.fn(async () => ({ id: "audit-1" })), }, }; const prisma = { ...tx, $transaction: vi.fn(async (callback) => callback(tx)), }; return { prisma, tx, state }; } async function buildApp(options = {}) { const { prisma, tx, state } = buildPrisma(options); const app = Fastify(); const permissionCodes = options.permissionCodes ?? ["invoice.verify", "invoice.manage"]; app.decorate("prisma", prisma); app.decorate("boss", { send: vi.fn(async () => ({ id: "job-invoice-verify-1" })), }); app.decorate("config", { invoiceVerificationEnabled: options.invoiceVerificationEnabled ?? true, verificationProvider: options.verificationProvider ?? "mock", verificationRetryCooldownSeconds: options.verificationRetryCooldownSeconds ?? 60, verificationMaxAttempts: options.verificationMaxAttempts ?? 3, jobDriver: "pgboss", }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = { id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, roleCodes: ["finance"], permissionCodes, }; }); app.decorate( "hasPermission", (employee, permissionCode) => (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(registerInvoiceVerificationRoutes, { prefix: "/api" }); return { app, prisma, tx, state }; } describe("invoicePool verification routes", () => { beforeEach(() => { vi.clearAllMocks(); }); it("creates a pending verification request and queues a job", async () => { const { app, tx } = await buildApp(); try { const response = await app.inject({ method: "POST", url: `/api/invoice-pool/${INVOICE_ID}/verify`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ invoiceId: INVOICE_ID, verificationId: RECORD_ID, provider: "mock", triggerMode: "queued", }); expect(tx.invoiceVerificationRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ invoice_id: INVOICE_ID, provider: "mock", status: "pending", requested_by: EMPLOYEE_ID, attempts: 1, request_payload: expect.objectContaining({ invoiceCode: "0440026", invoiceNumber: "00012890", departmentId: DEPARTMENT_ID, featureFlagReason: "flag_force_on_global", }), }), select: { id: true }, }); expect(tx.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ actor_id: EMPLOYEE_ID, action: "INVOICE_VERIFICATION_REQUESTED", target_type: "invoice_pool", target_id: INVOICE_ID, }), }); expect(app.boss.send).toHaveBeenCalledWith("invoice.verify", { recordId: RECORD_ID }); } finally { await app.close(); } }); it("blocks trigger when invoice verification is disabled by env gate", async () => { const { app, tx } = await buildApp({ invoiceVerificationEnabled: false }); try { const response = await app.inject({ method: "POST", url: `/api/invoice-pool/${INVOICE_ID}/verify`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(409); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { verification: "disabled", featureFlag: "env_hard_off" }, }); expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled(); expect(app.boss.send).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("blocks trigger when a pending verification already exists", async () => { const { app, tx } = await buildApp({ latestRecord: buildVerificationRecord({ status: "pending" }), }); try { const response = await app.inject({ method: "POST", url: `/api/invoice-pool/${INVOICE_ID}/verify`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ verification: "already_pending" }); expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("blocks trigger when retry cooldown has not elapsed", async () => { vi.setSystemTime(new Date("2026-05-28T08:00:20.000Z")); const { app, tx } = await buildApp({ latestRecord: buildVerificationRecord({ status: "failed", requested_at: new Date("2026-05-28T08:00:00.000Z"), }), verificationRetryCooldownSeconds: 60, }); try { const response = await app.inject({ method: "POST", url: `/api/invoice-pool/${INVOICE_ID}/verify`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ verification: "too_frequent", reasonCode: "invoice_verification.too_frequent" }); expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); await app.close(); } }); it("lists verification records for authorized finance user", async () => { const record = buildVerificationRecord(); const { app, tx } = await buildApp({ records: [record] }); try { const response = await app.inject({ method: "GET", url: `/api/invoice-pool/${INVOICE_ID}/verification?limit=1`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toMatchObject({ invoiceId: INVOICE_ID, items: [ { id: RECORD_ID, provider: "mock", status: "failed", attempts: 1, resultMessage: "invoice metadata mismatch", }, ], }); expect(tx.invoiceVerificationRecord.findMany).toHaveBeenCalledWith({ where: { invoice_id: INVOICE_ID }, orderBy: { created_at: "desc" }, take: 1, }); } finally { await app.close(); } }); it("creates retry record with incremented attempts and queues a job", async () => { vi.setSystemTime(new Date("2026-05-28T08:05:00.000Z")); const { app, tx } = await buildApp({ latestRecord: buildVerificationRecord({ id: RECORD_ID, status: "failed", attempts: 1, requested_at: new Date("2026-05-28T08:00:00.000Z"), }), verificationRetryCooldownSeconds: 60, }); try { const response = await app.inject({ method: "POST", url: `/api/invoice-pool/${INVOICE_ID}/verification/retry`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ invoiceId: INVOICE_ID, verificationId: RETRY_RECORD_ID, status: "pending", }); expect(tx.invoiceVerificationRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ invoice_id: INVOICE_ID, provider: "mock", status: "pending", retry_of_id: RECORD_ID, attempts: 2, requested_by: EMPLOYEE_ID, request_payload: expect.objectContaining({ retryOfId: RECORD_ID, departmentId: DEPARTMENT_ID, featureFlagReason: "flag_force_on_global", }), }), select: { id: true }, }); expect(tx.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ actor_id: EMPLOYEE_ID, action: "INVOICE_VERIFICATION_RETRIED", target_type: "invoice_pool", target_id: INVOICE_ID, }), }); expect(app.boss.send).toHaveBeenCalledWith("invoice.verify", { recordId: RETRY_RECORD_ID }); } finally { vi.useRealTimers(); await app.close(); } }); it("blocks retry after max attempts are exceeded", async () => { vi.setSystemTime(new Date("2026-05-28T08:05:00.000Z")); const { app, tx } = await buildApp({ latestRecord: buildVerificationRecord({ status: "error", attempts: 3, requested_at: new Date("2026-05-28T08:00:00.000Z"), }), verificationMaxAttempts: 3, verificationRetryCooldownSeconds: 60, }); try { const response = await app.inject({ method: "POST", url: `/api/invoice-pool/${INVOICE_ID}/verification/retry`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ verification: "attempts_exceeded", reasonCode: "invoice_verification.attempts_exceeded" }); expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled(); expect(app.boss.send).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); await app.close(); } }); });