const suggestions = require("../../../src/services/ai/suggestions"); const { buildReimbursementPrompt } = require("../../../src/services/ai/prompts"); const { AppError } = require("../../../src/utils/AppError"); 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 buildRequest(permissionCodes, employeeId = "employee-1") { return { id: "req-1", ip: "127.0.0.1", headers: { "user-agent": "vitest" }, employee: { id: employeeId, permissionCodes, }, }; } function buildBudgetPool(overrides = {}) { return { id: "budget-1", code: "B-001", name: "年度预算", department_id: "dept-1", legal_entity_id: null, cost_center_id: null, allocated_amount: 1000, used_amount: 920, warning_threshold_percent: 90, status: "active", archived_at: null, period_start: new Date("2026-03-01T00:00:00.000Z"), period_end: new Date("2026-03-31T00:00:00.000Z"), ...overrides, }; } function buildPrisma({ reimbursement = null, purchaseRequest = null, asset = null, budgetPools = [], flagRecord = buildFeatureFlagRecord(), auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" }), } = {}) { return { featureFlag: { findUnique: vi.fn().mockImplementation(async ({ where }) => { if (where.flag_key === "ai_assist") { return flagRecord; } return null; }), findMany: vi.fn().mockResolvedValue(flagRecord ? [flagRecord] : []), }, reimbursementForm: { findUnique: vi.fn().mockResolvedValue(reimbursement), }, purchaseRequest: { findUnique: vi.fn().mockResolvedValue(purchaseRequest), }, assetCard: { findUnique: vi.fn().mockResolvedValue(asset), }, budgetPool: { findMany: vi.fn().mockResolvedValue(budgetPools), }, auditLog: { create: auditCreate, }, }; } describe("AI suggestions service", () => { it("builds reimbursement suggestion with prompt hash and provider meta", 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: [{ id: "item-1", amount: 120, expense_type: { category: "travel" } }], invoice_pool_items: [{ status: "pending" }], workflow_instances: [ { workflow_tasks: [ { step_name: "财务复核", assignee_role: "finance_mgr", }, ], }, ], }, budgetPools: [buildBudgetPool()], }); const result = await suggestions.generateReimbursementSuggestion( prisma, { aiAssistEnabled: true, aiProvider: "mock", budgetControlMode: "warn" }, buildRequest(["expense.view_own", "ai.assist"]), "r-1", ); const expectedPrompt = buildReimbursementPrompt(result.sourceSnapshot); expect(result.providerMeta.provider).toBe("mock"); expect(result.providerMeta.promptHash).toBe( suggestions.__testables.buildPromptHash(expectedPrompt), ); expect(result.riskHints.length).toBeLessThanOrEqual(3); expect(result.nextActions.length).toBeLessThanOrEqual(3); expect(result.sourceSnapshot).not.toHaveProperty("owner"); expect(prisma.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "AI_SUGGESTION_REQUESTED", extra: expect.objectContaining({ featureFlag: "ai_assist", promptHash: result.providerMeta.promptHash, }), }), }), ); }); it("blocks purchase suggestion when AI gate is disabled by department rollout", async () => { const 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, }, }); await expect( suggestions.generatePurchaseSuggestion( prisma, { aiAssistEnabled: true, aiProvider: "mock" }, buildRequest(["procurement.view_all", "ai.assist"]), "p-1", ), ).rejects.toMatchObject({ statusCode: 409, details: expect.objectContaining({ ai: "disabled", reasonCode: "ai.feature_disabled", featureFlagReason: "department_not_allowed", }), }); }); it("rejects asset own-view access when actor is not assignee", async () => { const prisma = buildPrisma({ asset: { id: "a-1", assignee_employee_id: "employee-1", asset_no: "ZC-001", name: "Laptop", status: "in_use", 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, }, }); await expect( suggestions.generateAssetSuggestion( prisma, { aiAssistEnabled: true, aiProvider: "mock" }, buildRequest(["asset.view_own", "ai.assist"], "employee-2"), "a-1", ), ).rejects.toBeInstanceOf(AppError); await expect( suggestions.generateAssetSuggestion( prisma, { aiAssistEnabled: true, aiProvider: "mock" }, buildRequest(["asset.view_own", "ai.assist"], "employee-2"), "a-1", ), ).rejects.toMatchObject({ statusCode: 403, code: "PERM_001" }); }); it("normalizes malformed provider payload to capped structured items", () => { const normalized = suggestions.normalizeProviderSuggestion("reimbursement", { summary: "x".repeat(140), riskHints: [{ level: "critical", code: "", title: "", detail: "" }], nextActions: [{ code: "", label: "", reason: "" }], }); expect(normalized.summary.length).toBeLessThanOrEqual(120); expect(normalized.riskHints[0]).toEqual( expect.objectContaining({ level: "low", code: "risk_1" }), ); expect(normalized.nextActions[0]).toEqual( expect.objectContaining({ code: "action_1", label: "建议动作 1" }), ); }); });