const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const aiRoutes = require("../../../src/routes/ai"); function buildFeatureFlagRecord({ overrideMode = "force_on", rolloutType = "global", departmentIds = [], } = {}) { const now = new Date("2026-03-10T00:00:00.000Z"); return { id: "flag-ai-assist", flag_key: "ai_assist", 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 buildPrisma({ reimbursement = null, purchaseRequest = null, asset = null, flagRecord = buildFeatureFlagRecord(), } = {}) { return { featureFlag: { findUnique: vi.fn().mockImplementation(async ({ where }) => { if (where.flag_key === "ai_assist") { return flagRecord; } return null; }), }, reimbursementForm: { findUnique: vi.fn().mockResolvedValue(reimbursement), }, purchaseRequest: { findUnique: vi.fn().mockResolvedValue(purchaseRequest), }, assetCard: { findUnique: vi.fn().mockResolvedValue(asset), }, budgetPool: { findMany: vi.fn().mockResolvedValue([]), }, auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }), }, }; } async function buildTestApp({ employee, prisma, config }) { const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", { aiAssistEnabled: true, aiProvider: "mock", budgetControlMode: "warn", ...config, }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401, }); } request.employee = employee; }); 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(aiRoutes, { prefix: "/api" }); return app; } describe("AI routes", () => { it("returns AI status for authenticated user", async () => { const app = await buildTestApp({ employee: { id: "employee-1", permissionCodes: [] }, prisma: buildPrisma(), config: { aiAssistEnabled: false }, }); const response = await app.inject({ method: "GET", url: "/api/ai/status", headers: { authorization: "Bearer test-token" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ enabled: false, permissions: { canAccess: false }, featureFlag: expect.objectContaining({ effectiveReason: "env_hard_off" }), }), ); await app.close(); }); it("returns 401 when auth header is missing", async () => { const app = await buildTestApp({ employee: { id: "employee-1", permissionCodes: ["ai.assist"] }, prisma: buildPrisma(), }); const response = await app.inject({ method: "GET", url: "/api/ai/status" }); expect(response.statusCode).toBe(401); expect(response.json().error.code).toBe("AUTH_001"); await app.close(); }); it("rejects reimbursement suggestion when actor lacks ai.assist", async () => { const app = await buildTestApp({ employee: { id: "employee-1", permissionCodes: ["expense.view_own"] }, prisma: buildPrisma({ reimbursement: { id: "r-1", form_number: "BX-001", submitter_id: "employee-2", owner_id: "employee-1", department_id: "dept-1", reimbursement_date: new Date("2026-03-10T00:00:00.000Z"), total_amount: 120, status: "reviewing", items: [], invoice_pool_items: [], workflow_instances: [], }, }), }); const response = await app.inject({ method: "POST", url: "/api/ai/suggestions/reimbursements/11111111-1111-4111-8111-111111111111", headers: { authorization: "Bearer test-token" }, payload: {}, }); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); await app.close(); }); it("returns 409 when purchase suggestion is disabled by department gate", async () => { const app = await buildTestApp({ employee: { id: "employee-1", permissionCodes: ["procurement.view_all", "ai.assist"] }, prisma: buildPrisma({ flagRecord: buildFeatureFlagRecord({ overrideMode: "force_on", rolloutType: "department_allowlist", departmentIds: ["dept-2"], }), purchaseRequest: { id: "p-1", request_no: "PR-001", applicant_id: "employee-1", submitter_id: "employee-1", department_id: "dept-1", title: "办公电脑采购", currency_code: "CNY", total_amount: 6000, status: "approved", supplier: { code: "SUP-1", name: "供应商" }, legal_entity: { code: "HY", name: "海音达" }, cost_center: { code: "IT001", name: "IT中心" }, budget_pool: { code: "B-001", name: "年度预算" }, items: [{ is_asset_candidate: true }], order_archive: null, }, }), }); const response = await app.inject({ method: "POST", url: "/api/ai/suggestions/purchase-requests/22222222-2222-4222-8222-222222222222", headers: { authorization: "Bearer test-token" }, payload: {}, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual( expect.objectContaining({ ai: "disabled", featureFlagReason: "department_not_allowed" }), ); await app.close(); }); it("enforces reimbursement own-view permission on suggestion route", async () => { const prisma = buildPrisma({ reimbursement: { id: "r-1", form_number: "BX-001", submitter_id: "employee-2", owner_id: "employee-1", department_id: "dept-1", reimbursement_date: new Date("2026-03-10T00:00:00.000Z"), total_amount: 120, status: "reviewing", items: [], invoice_pool_items: [], workflow_instances: [], }, }); const ownerApp = await buildTestApp({ employee: { id: "employee-1", permissionCodes: ["expense.view_own", "ai.assist"] }, prisma, }); const ownerResponse = await ownerApp.inject({ method: "POST", url: "/api/ai/suggestions/reimbursements/11111111-1111-4111-8111-111111111111", headers: { authorization: "Bearer test-token" }, payload: {}, }); expect(ownerResponse.statusCode).toBe(200); expect(ownerResponse.json().data.targetType).toBe("reimbursement"); await ownerApp.close(); const outsiderApp = await buildTestApp({ employee: { id: "employee-9", permissionCodes: ["expense.view_own", "ai.assist"] }, prisma, }); const outsiderResponse = await outsiderApp.inject({ method: "POST", url: "/api/ai/suggestions/reimbursements/11111111-1111-4111-8111-111111111111", headers: { authorization: "Bearer test-token" }, payload: {}, }); expect(outsiderResponse.statusCode).toBe(403); expect(outsiderResponse.json().error.code).toBe("PERM_001"); await outsiderApp.close(); }); it("enforces asset assignee own-view permission on suggestion route", async () => { const prisma = buildPrisma({ asset: { id: "a-1", asset_no: "ZC-001", name: "Laptop", status: "in_use", assignee_employee_id: "employee-1", category: { code: "LAPTOP", name: "笔记本电脑", document_category: { code: "ASSET_STD" } }, legal_entity: { code: "HY", name: "海音达" }, cost_center: { code: "IT001", name: "IT中心" }, purchase_request: null, location: "办公室", activated_at: new Date("2026-03-10T00:00:00.000Z"), retired_at: null, }, }); const ownerApp = await buildTestApp({ employee: { id: "employee-1", permissionCodes: ["asset.view_own", "ai.assist"] }, prisma, }); const ownerResponse = await ownerApp.inject({ method: "POST", url: "/api/ai/suggestions/assets/33333333-3333-4333-8333-333333333333", headers: { authorization: "Bearer test-token" }, payload: {}, }); expect(ownerResponse.statusCode).toBe(200); expect(ownerResponse.json().data.targetType).toBe("asset_card"); await ownerApp.close(); const outsiderApp = await buildTestApp({ employee: { id: "employee-9", permissionCodes: ["asset.view_own", "ai.assist"] }, prisma, }); const outsiderResponse = await outsiderApp.inject({ method: "POST", url: "/api/ai/suggestions/assets/33333333-3333-4333-8333-333333333333", headers: { authorization: "Bearer test-token" }, payload: {}, }); expect(outsiderResponse.statusCode).toBe(403); expect(outsiderResponse.json().error.code).toBe("PERM_001"); await outsiderApp.close(); }); });