const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const purchaseRequestRoutes = require("../../../src/routes/purchaseRequests"); const PURCHASE_REQUEST_ID = "11111111-1111-4111-8111-111111111111"; const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222"; const OTHER_EMPLOYEE_ID = "33333333-3333-4333-8333-333333333333"; const DEPARTMENT_ID = "44444444-4444-4444-8444-444444444444"; const PROJECT_ID = "55555555-5555-4555-8555-555555555555"; const NOW = new Date("2026-05-13T10:00:00.000Z"); function buildFeatureFlagRecord(flagKey) { return { id: `flag-${flagKey}`, flag_key: flagKey, override_mode: "force_on", rollout_type: "global", department_ids: [], note: "unit-test", version: 1, created_at: NOW, created_by: null, updated_at: NOW, updated_by: null, }; } function buildProject(overrides = {}) { return { id: PROJECT_ID, project_no: "PRJ-BIZ8", name: "BIZ8 测试项目", status: "active", owner_employee_id: EMPLOYEE_ID, manager_employee_id: null, members: [], ...overrides, }; } function buildPurchaseRequest(overrides = {}) { return { id: PURCHASE_REQUEST_ID, request_no: null, applicant_id: EMPLOYEE_ID, submitter_id: null, department_id: DEPARTMENT_ID, legal_entity_id: null, cost_center_id: null, supplier_id: null, document_category_id: null, budget_pool_id: null, project_id: null, title: "项目关联采购草稿", reason: null, currency_code: "CNY", total_amount: "0.00", expected_delivery_date: null, status: "draft", created_at: NOW, updated_at: NOW, applicant: { id: EMPLOYEE_ID, employee_no: "E001", name: "申请人" }, submitter: null, department: { id: DEPARTMENT_ID, name: "测试部门" }, legal_entity: null, cost_center: null, supplier: null, document_category: null, budget_pool: null, project: null, items: [], attachments: [], order_archive: null, workflow_instances: [], ...overrides, }; } async function buildApp({ project = buildProject(), actorId = EMPLOYEE_ID, permissionCodes = ["procurement.create", "procurement.view_own", "project.view_own"], purchaseRequestOverrides = {}, purchaseRequests = null, } = {}) { const app = Fastify(); const actor = { id: actorId, department_id: DEPARTMENT_ID, roleCodes: [], permissionCodes, }; const state = { purchaseRequest: buildPurchaseRequest(purchaseRequestOverrides), purchaseRequests: null, auditEntries: [], }; state.purchaseRequests = purchaseRequests?.map((item) => buildPurchaseRequest(item)) ?? [state.purchaseRequest]; function attachProjectRelation(item) { return { ...item, project: item.project_id === project.id ? { id: project.id, project_no: project.project_no, name: project.name, status: project.status, } : null, }; } const tx = { $queryRaw: vi.fn(async () => [state.purchaseRequest]), featureFlag: { findUnique: vi.fn(async ({ where }) => buildFeatureFlagRecord(where.flag_key)), }, project: { findUnique: vi.fn(async ({ where }) => (where.id === project.id ? project : null)), }, purchaseRequest: { update: vi.fn(async ({ data }) => { state.purchaseRequest = { ...state.purchaseRequest, ...(data.project_id !== undefined ? { project_id: data.project_id } : {}), ...(data.title !== undefined ? { title: data.title } : {}), ...(data.reason !== undefined ? { reason: data.reason } : {}), ...(data.currency_code !== undefined ? { currency_code: data.currency_code } : {}), ...(data.total_amount !== undefined ? { total_amount: data.total_amount } : {}), updated_by: data.updated_by, updated_at: NOW, }; return attachProjectRelation(state.purchaseRequest); }), findUnique: vi.fn(async () => attachProjectRelation(state.purchaseRequest)), }, auditLog: { create: vi.fn(async ({ data }) => { state.auditEntries.push(data); return { id: "audit-1" }; }), }, }; function listMatchesWhere(item, where = {}) { if (where.project_id && item.project_id !== where.project_id) { return false; } if (where.status && item.status !== where.status) { return false; } if (where.OR) { return where.OR.some((entry) => { if (entry.applicant_id) return item.applicant_id === entry.applicant_id; if (entry.submitter_id) return item.submitter_id === entry.submitter_id; return false; }); } return true; } const prisma = { $transaction: vi.fn(async (callback) => callback(tx)), purchaseRequest: { findUnique: vi.fn(async () => attachProjectRelation(state.purchaseRequest)), findMany: vi.fn(async ({ where }) => state.purchaseRequests.filter((item) => listMatchesWhere(item, where)).map(attachProjectRelation)), count: vi.fn(async ({ where }) => state.purchaseRequests.filter((item) => listMatchesWhere(item, where)).length), }, entityRiskFlag: { findMany: vi.fn(async () => []), }, }; app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", projectLinkingEnabled: true, cutoverConsoleEnabled: false, }); app.decorate("hasPermission", (employee, permissionCode) => { const permissionCodes = employee?.permissionCodes ?? []; return permissionCodes.includes("*") || permissionCodes.includes(permissionCode); }); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = actor; if (!app.hasPermission(actor, permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.decorate("authenticate", async (request) => { request.employee = actor; }); 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(purchaseRequestRoutes, { prefix: "/api" }); return { app, tx, state }; } describe("purchase request project link", () => { it("links draft purchase request to a readable active project", async () => { const { app, tx, state } = await buildApp(); const response = await app.inject({ method: "PATCH", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}`, payload: { projectId: PROJECT_ID }, }); expect(response.statusCode).toBe(200); expect(response.json().data.purchaseRequest).toEqual( expect.objectContaining({ id: PURCHASE_REQUEST_ID, projectId: PROJECT_ID, project: { id: PROJECT_ID, projectNo: "PRJ-BIZ8", name: "BIZ8 测试项目", status: "active", }, }), ); expect(state.purchaseRequest.project_id).toBe(PROJECT_ID); expect(tx.auditLog.create).toHaveBeenCalledTimes(1); await app.close(); }); it("blocks archived projects from new purchase links", async () => { const { app, tx } = await buildApp({ project: buildProject({ status: "archived" }), }); const response = await app.inject({ method: "PATCH", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}`, payload: { projectId: PROJECT_ID }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ projectId: "archived" }); expect(tx.purchaseRequest.update).not.toHaveBeenCalled(); await app.close(); }); it("does not revalidate an unchanged existing project link during draft edits", async () => { const { app, tx } = await buildApp({ project: buildProject({ status: "archived" }), purchaseRequestOverrides: { project_id: PROJECT_ID, }, }); const response = await app.inject({ method: "PATCH", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}`, payload: { projectId: PROJECT_ID, title: "只修改标题" }, }); expect(response.statusCode).toBe(200); expect(response.json().data.purchaseRequest).toEqual( expect.objectContaining({ projectId: PROJECT_ID, title: "只修改标题", project: expect.objectContaining({ status: "archived" }), }), ); expect(tx.project.findUnique).not.toHaveBeenCalled(); await app.close(); }); it("does not let unreadable projects be linked to purchase drafts", async () => { const { app, tx } = await buildApp({ project: buildProject({ owner_employee_id: OTHER_EMPLOYEE_ID }), }); const response = await app.inject({ method: "PATCH", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}`, payload: { projectId: PROJECT_ID }, }); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); expect(tx.purchaseRequest.update).not.toHaveBeenCalled(); await app.close(); }); it("filters purchase request list by project id", async () => { const otherProjectId = "66666666-6666-4666-8666-666666666666"; const { app } = await buildApp({ permissionCodes: ["procurement.view_all"], purchaseRequests: [ { id: PURCHASE_REQUEST_ID, project_id: PROJECT_ID, request_no: "PR-001", title: "关联项目采购", status: "draft", }, { id: "77777777-7777-4777-8777-777777777777", project_id: otherProjectId, request_no: "PR-002", title: "其他项目采购", status: "draft", }, ], }); const response = await app.inject({ method: "GET", url: `/api/purchase-requests?projectId=${PROJECT_ID}&mine=0`, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ total: 1, items: [ expect.objectContaining({ id: PURCHASE_REQUEST_ID, projectId: PROJECT_ID, project: expect.objectContaining({ projectNo: "PRJ-BIZ8", name: "BIZ8 测试项目", }), }), ], }), ); await app.close(); }); it("does not let project membership bypass purchase detail permission", async () => { const { app } = await buildApp({ actorId: OTHER_EMPLOYEE_ID, project: buildProject({ owner_employee_id: OTHER_EMPLOYEE_ID }), permissionCodes: ["procurement.view_own", "project.view_own"], purchaseRequestOverrides: { applicant_id: EMPLOYEE_ID, project_id: PROJECT_ID, }, }); const response = await app.inject({ method: "GET", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}`, }); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); await app.close(); }); });