const { enableNumbering } = require("../helpers/activeNumbering"); const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const projectsRoutes = require("../../../src/routes/projects"); const PROJECT_ID = "11111111-1111-4111-8111-111111111111"; const OWNER_ID = "22222222-2222-4222-8222-222222222222"; const MANAGER_ID = "33333333-3333-4333-8333-333333333333"; const MEMBER_ID = "44444444-4444-4444-8444-444444444444"; const OUTSIDER_ID = "55555555-5555-4555-8555-555555555555"; const NOW = new Date("2026-03-22T09:30: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 buildEmployee(id, name, employeeNo) { return { id, name, employee_no: employeeNo }; } function buildProjectState(overrides = {}) { return { id: PROJECT_ID, project_no: "PRJ-001", name: "试点项目", client_name: "测试客户", client_alias: "测试客户简称", owner_employee_id: OWNER_ID, manager_employee_id: MANAGER_ID, status: "active", starts_at: new Date("2026-03-01T00:00:00.000Z"), target_end_at: new Date("2026-03-31T00:00:00.000Z"), actual_end_at: null, summary: "用于 R23 单测的项目", is_confidential: false, created_at: NOW, updated_at: NOW, owner: buildEmployee(OWNER_ID, "Owner", "E001"), manager: buildEmployee(MANAGER_ID, "Manager", "E002"), members: [ { id: "aaaaaaa1-1111-4111-8111-111111111111", employee_id: MEMBER_ID, role: "member", joined_at: NOW, left_at: null, employee: buildEmployee(MEMBER_ID, "Member", "E003"), }, ], milestones: [], tasks: [], risks: [], ...overrides, }; } function createPrismaState({ communicationLogs = [], closureChecklistItems = [], purchaseRequests = [] } = {}) { const state = { project: buildProjectState(), communicationLogs: communicationLogs.map((item) => ({ ...item })), closureChecklistItems: closureChecklistItems.map((item) => ({ ...item })), purchaseRequests: purchaseRequests.map((item) => ({ ...item })), openRiskCount: 0, openReimbursementCount: 0, openTravelCount: 0, deliveryRecordCount: 0, contractCount: 0, confidentialDocRefCount: 0, auditEntries: [], }; function withProjectCounts(project) { return { ...project, _count: { members: (project.members ?? []).length, milestones: (project.milestones ?? []).length, tasks: (project.tasks ?? []).length, risks: (project.risks ?? []).length, reimbursements: 0, purchase_requests: state.purchaseRequests.filter((item) => item.project_id === project.id).length, travel_requests: 0, todo_items: 0, weekly_reports: 0, communication_logs: state.communicationLogs.length, closure_checklist_items: state.closureChecklistItems.length, contracts: state.contractCount, deliveries: state.deliveryRecordCount, document_refs: state.confidentialDocRefCount, }, }; } function nextUuid(seed) { return `99999999-9999-4999-8999-${String(seed).padStart(12, "0")}`; } function attachChecklistCompleter(item) { return { ...item, completer: item.completed_by ? buildEmployee(item.completed_by, item.completed_by === OWNER_ID ? "Owner" : "Manager", "E999") : null, }; } function attachCommunicationCreator(item) { const creators = { [OWNER_ID]: buildEmployee(OWNER_ID, "Owner", "E001"), [MANAGER_ID]: buildEmployee(MANAGER_ID, "Manager", "E002"), [MEMBER_ID]: buildEmployee(MEMBER_ID, "Member", "E003"), }; return { ...item, creator: creators[item.created_by] ?? buildEmployee(item.created_by, "Unknown", "E000"), }; } const prisma = { businessNumberPolicy: { findUnique: vi.fn(async () => null) }, $executeRaw: vi.fn(async () => 0), featureFlag: { findUnique: vi.fn().mockImplementation(async ({ where }) => buildFeatureFlagRecord(where.flag_key)), }, project: { findUnique: vi.fn().mockImplementation(async () => withProjectCounts(state.project)), update: vi.fn().mockImplementation(async ({ data }) => { state.project = { ...state.project, ...(data.name !== undefined ? { name: data.name } : {}), ...(data.client_name !== undefined ? { client_name: data.client_name } : {}), ...(data.client_alias !== undefined ? { client_alias: data.client_alias } : {}), ...(data.owner_employee_id !== undefined ? { owner_employee_id: data.owner_employee_id } : {}), ...(data.manager_employee_id !== undefined ? { manager_employee_id: data.manager_employee_id } : {}), ...(data.status !== undefined ? { status: data.status } : {}), ...(data.starts_at !== undefined ? { starts_at: data.starts_at } : {}), ...(data.target_end_at !== undefined ? { target_end_at: data.target_end_at } : {}), ...(data.actual_end_at !== undefined ? { actual_end_at: data.actual_end_at } : {}), ...(data.summary !== undefined ? { summary: data.summary } : {}), ...(data.is_confidential !== undefined ? { is_confidential: data.is_confidential } : {}), updated_at: NOW, }; return withProjectCounts(state.project); }), }, projectCommunicationLog: { count: vi.fn().mockImplementation(async ({ where }) => { const items = state.communicationLogs.filter((item) => item.project_id === where.project_id); if (!where.next_follow_up_date) { return items.length; } return items.filter((item) => { if (!item.next_follow_up_date) { return false; } const next = new Date(item.next_follow_up_date).getTime(); if (where.next_follow_up_date.gte && where.next_follow_up_date.lte) { return next >= new Date(where.next_follow_up_date.gte).getTime() && next <= new Date(where.next_follow_up_date.lte).getTime(); } if (where.next_follow_up_date.lt) { return next < new Date(where.next_follow_up_date.lt).getTime(); } return false; }).length; }), findFirst: vi.fn().mockImplementation(async ({ where }) => { const items = state.communicationLogs .filter((item) => item.project_id === where.project_id) .sort((left, right) => new Date(right.happened_at).getTime() - new Date(left.happened_at).getTime()); return items[0] ? attachCommunicationCreator(items[0]) : null; }), findMany: vi.fn().mockImplementation(async ({ where }) => { return state.communicationLogs .filter((item) => item.project_id === where.project_id) .sort((left, right) => new Date(right.happened_at).getTime() - new Date(left.happened_at).getTime()) .map((item) => attachCommunicationCreator(item)); }), create: vi.fn().mockImplementation(async ({ data }) => { const created = { id: nextUuid(state.communicationLogs.length + 1), project_id: data.project_id, happened_at: data.happened_at, channel: data.channel, external_contact_name: data.external_contact_name, external_org_name: data.external_org_name, summary: data.summary, next_action: data.next_action, next_follow_up_date: data.next_follow_up_date, created_by: data.created_by, created_at: NOW, updated_at: NOW, }; state.communicationLogs.unshift(created); return attachCommunicationCreator(created); }), findUnique: vi.fn().mockImplementation(async ({ where }) => { const item = state.communicationLogs.find((entry) => entry.id === where.id); return item ? attachCommunicationCreator(item) : null; }), update: vi.fn().mockImplementation(async ({ where, data }) => { const index = state.communicationLogs.findIndex((entry) => entry.id === where.id); if (index < 0) { return null; } state.communicationLogs[index] = { ...state.communicationLogs[index], ...(data.happened_at !== undefined ? { happened_at: data.happened_at } : {}), ...(data.channel !== undefined ? { channel: data.channel } : {}), ...(data.external_contact_name !== undefined ? { external_contact_name: data.external_contact_name } : {}), ...(data.external_org_name !== undefined ? { external_org_name: data.external_org_name } : {}), ...(data.summary !== undefined ? { summary: data.summary } : {}), ...(data.next_action !== undefined ? { next_action: data.next_action } : {}), ...(data.next_follow_up_date !== undefined ? { next_follow_up_date: data.next_follow_up_date } : {}), updated_at: NOW, }; return attachCommunicationCreator(state.communicationLogs[index]); }), }, projectClosureChecklistItem: { count: vi.fn().mockImplementation(async ({ where }) => state.closureChecklistItems.filter((item) => item.project_id === where.project_id).length), createMany: vi.fn().mockImplementation(async ({ data }) => { data.forEach((item, index) => { if (state.closureChecklistItems.some((current) => current.project_id === item.project_id && current.item_key === item.item_key)) { return; } state.closureChecklistItems.push({ id: nextUuid(100 + state.closureChecklistItems.length + index), project_id: item.project_id, item_key: item.item_key, title: item.title, status: item.status, note: null, sort_order: item.sort_order, completed_at: null, completed_by: null, created_at: NOW, updated_at: NOW, }); }); return { count: data.length }; }), findMany: vi.fn().mockImplementation(async ({ where, select }) => { const items = state.closureChecklistItems .filter((item) => item.project_id === where.project_id) .sort((left, right) => left.sort_order - right.sort_order); if (select?.status) { return items.map((item) => ({ status: item.status })); } return items.map((item) => attachChecklistCompleter(item)); }), findUnique: vi.fn().mockImplementation(async ({ where }) => { const item = state.closureChecklistItems.find((entry) => entry.id === where.id); return item ? attachChecklistCompleter(item) : null; }), update: vi.fn().mockImplementation(async ({ where, data }) => { const index = state.closureChecklistItems.findIndex((entry) => entry.id === where.id); if (index < 0) { return null; } state.closureChecklistItems[index] = { ...state.closureChecklistItems[index], ...(data.status !== undefined ? { status: data.status } : {}), ...(data.note !== undefined ? { note: data.note } : {}), ...(data.completed_at !== undefined ? { completed_at: data.completed_at } : {}), ...(data.completed_by !== undefined ? { completed_by: data.completed_by } : {}), updated_at: NOW, }; return attachChecklistCompleter(state.closureChecklistItems[index]); }), }, projectRisk: { count: vi.fn().mockImplementation(async () => state.openRiskCount) }, reimbursementForm: { count: vi.fn().mockImplementation(async () => state.openReimbursementCount) }, travelRequest: { count: vi.fn().mockImplementation(async () => state.openTravelCount) }, purchaseRequest: { findMany: vi.fn().mockImplementation(async ({ where }) => { return state.purchaseRequests .filter((item) => item.project_id === where.project_id) .sort((left, right) => new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime()); }), }, projectDeliveryRecord: { count: vi.fn().mockImplementation(async () => state.deliveryRecordCount) }, contractRegistry: { count: vi.fn().mockImplementation(async () => state.contractCount) }, confidentialDocumentRef: { count: vi.fn().mockImplementation(async () => state.confidentialDocRefCount) }, auditLog: { create: vi.fn().mockImplementation(async ({ data }) => { state.auditEntries.push(data); return { id: nextUuid(500 + state.auditEntries.length) }; }), }, }; prisma.$transaction = vi.fn(async (cb) => cb(prisma)); return { state, prisma }; } async function buildApp({ employee, prisma }) { const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", { projectDomainEnabled: true, projectLinkingEnabled: true, projectCommunicationLogEnabled: true, projectClosureChecklistEnabled: true, confidentialDocRefsEnabled: true, }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = employee; }); app.decorate("hasPermission", (actor, permissionCode) => { return (actor?.permissionCodes ?? []).includes("*") || (actor?.permissionCodes ?? []).includes(permissionCode); }); 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(projectsRoutes, { prefix: "/api" }); return app; } describe("project R23 routes", () => { it("returns purchase request summary only to readable project users", async () => { const { prisma } = createPrismaState({ purchaseRequests: [ { id: "77777777-7777-4777-8777-777777777777", project_id: PROJECT_ID, request_no: "PR-20260513-001", title: "测试设备采购", status: "approved", total_amount: "1200.50", updated_at: new Date("2026-05-13T08:00:00.000Z"), }, { id: "88888888-8888-4888-8888-888888888888", project_id: PROJECT_ID, request_no: null, title: "未编号采购草稿", status: "draft", total_amount: "99.50", updated_at: new Date("2026-05-13T09:00:00.000Z"), }, ], }); const memberApp = await buildApp({ employee: { id: MEMBER_ID, department_id: null, permissionCodes: ["project.view_own"] }, prisma, }); const response = await memberApp.inject({ method: "GET", url: `/api/projects/${PROJECT_ID}/purchase-requests/summary`, headers: { authorization: "Bearer test-token" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ totalAmount: "1300.00", items: [ { id: "88888888-8888-4888-8888-888888888888", requestNo: "88888888-8888-4888-8888-888888888888", title: "未编号采购草稿", status: "draft", totalAmount: "99.50", updatedAt: "2026-05-13T09:00:00.000Z", }, { id: "77777777-7777-4777-8777-777777777777", requestNo: "PR-20260513-001", title: "测试设备采购", status: "approved", totalAmount: "1200.50", updatedAt: "2026-05-13T08:00:00.000Z", }, ], }); const outsiderApp = await buildApp({ employee: { id: OUTSIDER_ID, department_id: null, permissionCodes: ["project.view_own"] }, prisma, }); const forbiddenResponse = await outsiderApp.inject({ method: "GET", url: `/api/projects/${PROJECT_ID}/purchase-requests/summary`, headers: { authorization: "Bearer test-token" }, }); expect(forbiddenResponse.statusCode).toBe(403); expect(forbiddenResponse.json().error.code).toBe("PERM_001"); await Promise.all([memberApp.close(), outsiderApp.close()]); }); it("allows project member to create communication logs and blocks outsiders", async () => { const { prisma } = createPrismaState(); const memberApp = await buildApp({ employee: { id: MEMBER_ID, department_id: null, permissionCodes: ["project.view_own"] }, prisma, }); const createResponse = await memberApp.inject({ method: "POST", url: `/api/projects/${PROJECT_ID}/communication-logs`, headers: { authorization: "Bearer test-token" }, payload: { happenedAt: "2026-03-22T08:00:00.000Z", channel: "wechat", externalContactName: "王总", externalOrgName: "测试客户", summary: "客户确认下周一现场启动。", nextAction: "准备启动材料", nextFollowUpDate: "2026-03-24", }, }); expect(createResponse.statusCode).toBe(201); expect(createResponse.json().data.item).toEqual( expect.objectContaining({ channel: "wechat", summary: "客户确认下周一现场启动。", canEdit: true, }), ); const outsiderApp = await buildApp({ employee: { id: OUTSIDER_ID, department_id: null, permissionCodes: ["project.view_own"] }, prisma, }); const outsiderResponse = await outsiderApp.inject({ method: "GET", url: `/api/projects/${PROJECT_ID}/communication-logs`, headers: { authorization: "Bearer test-token" }, }); expect(outsiderResponse.statusCode).toBe(403); expect(outsiderResponse.json().error.code).toBe("PERM_001"); await Promise.all([memberApp.close(), outsiderApp.close()]); }); it("enforces 24h edit window for creators while allowing owners to edit anytime", async () => { const { state, prisma } = createPrismaState({ communicationLogs: [ { id: "66666666-6666-4666-8666-666666666666", project_id: PROJECT_ID, happened_at: new Date("2026-03-21T08:00:00.000Z"), channel: "phone", external_contact_name: "李总", external_org_name: "测试客户", summary: "客户电话确认测试安排。", next_action: "同步排期", next_follow_up_date: new Date("2026-03-23T00:00:00.000Z"), created_by: MEMBER_ID, created_at: new Date("2026-03-20T08:00:00.000Z"), updated_at: new Date("2026-03-20T08:00:00.000Z"), }, ], }); const staleCreatorApp = await buildApp({ employee: { id: MEMBER_ID, department_id: null, permissionCodes: ["project.view_own"] }, prisma, }); const staleResponse = await staleCreatorApp.inject({ method: "PATCH", url: `/api/projects/${PROJECT_ID}/communication-logs/66666666-6666-4666-8666-666666666666`, headers: { authorization: "Bearer test-token" }, payload: { summary: "尝试修改过期记录" }, }); expect(staleResponse.statusCode).toBe(403); const ownerApp = await buildApp({ employee: { id: OWNER_ID, department_id: null, permissionCodes: ["project.view_own"] }, prisma, }); const ownerResponse = await ownerApp.inject({ method: "PATCH", url: `/api/projects/${PROJECT_ID}/communication-logs/66666666-6666-4666-8666-666666666666`, headers: { authorization: "Bearer test-token" }, payload: { summary: "Owner 已补充最新沟通结果" }, }); expect(ownerResponse.statusCode).toBe(200); expect(ownerResponse.json().data.item.summary).toBe("Owner 已补充最新沟通结果"); expect(state.communicationLogs[0].summary).toBe("Owner 已补充最新沟通结果"); await Promise.all([staleCreatorApp.close(), ownerApp.close()]); }); it("bootstraps closure checklist and requires note when waiving", async () => { const { prisma } = createPrismaState(); const app = await buildApp({ employee: { id: OWNER_ID, department_id: null, permissionCodes: ["project.view_own"] }, prisma, }); const listResponse = await app.inject({ method: "GET", url: `/api/projects/${PROJECT_ID}/closure-checklist`, headers: { authorization: "Bearer test-token" }, }); expect(listResponse.statusCode).toBe(200); expect(listResponse.json().data.items).toHaveLength(6); expect(prisma.projectClosureChecklistItem.createMany).toHaveBeenCalledTimes(1); const firstItemId = listResponse.json().data.items[0].id; const waiveResponse = await app.inject({ method: "PATCH", url: `/api/projects/${PROJECT_ID}/closure-checklist/${firstItemId}`, headers: { authorization: "Bearer test-token" }, payload: { status: "waived", note: "" }, }); expect(waiveResponse.statusCode).toBe(400); expect(waiveResponse.json().error.details).toEqual(expect.objectContaining({ note: "waived_requires_note" })); await app.close(); }); it("returns closureWarning when project is completed with pending checklist items", async () => { const { prisma } = createPrismaState(); const app = await buildApp({ employee: { id: OWNER_ID, department_id: null, permissionCodes: ["project.view_own"] }, prisma, }); const response = await app.inject({ method: "PATCH", url: `/api/projects/${PROJECT_ID}`, headers: { authorization: "Bearer test-token" }, payload: { status: "completed" }, }); expect(response.statusCode).toBe(200); expect(response.json().data.item.status).toBe("completed"); expect(response.json().data.closureWarning).toEqual( expect.objectContaining({ pendingCount: 6 }), ); await app.close(); }); }); const ERROR_CHILD_ID = "66666666-6666-4666-8666-666666666666"; const errorApps = []; afterEach(async () => { await Promise.all(errorApps.splice(0).map((app) => app.close())); }); async function errorContext({ employee = { id: OWNER_ID, permissionCodes: ["project.manage"] }, emptyChecklist = false } = {}) { const context = createPrismaState({ communicationLogs: [{ id: ERROR_CHILD_ID, project_id: PROJECT_ID, summary: "合成沟通", channel: "meeting", happened_at: NOW, created_at: NOW, created_by: OWNER_ID }], closureChecklistItems: emptyChecklist ? [] : [{ id: ERROR_CHILD_ID, project_id: PROJECT_ID, status: "pending", title: "合成收口项", sort_order: 1, note: null }], }); const { prisma, state } = context; for (const model of ["projectMember", "projectMilestone", "projectTask", "projectRisk", "projectDeliveryRecord"]) { prisma[model] ??= {}; for (const method of ["create", "update", "upsert"]) prisma[model][method] = vi.fn(async ({ data, create }) => ({ id: ERROR_CHILD_ID, project_id: PROJECT_ID, ...(data ?? create) })); } prisma.project.create = vi.fn(async ({ data }) => ({ ...state.project, project_no: data.project_no })); prisma.project.findMany = vi.fn().mockResolvedValue([]); const app = await buildApp({ employee, prisma }); errorApps.push(app); return { ...context, app }; } function errorRequest(method, suffix, payload) { return { method, url: `/api/projects${suffix}`, headers: { authorization: "Bearer synthetic-test" }, ...(payload ? { payload } : {}) }; } function noProjectMutations(prisma, { bootstrap = false } = {}) { for (const model of ["project", "projectMember", "projectMilestone", "projectTask", "projectRisk", "projectDeliveryRecord", "projectCommunicationLog", "projectClosureChecklistItem"]) { for (const method of ["create", "update", "upsert"]) if (prisma[model]?.[method]) expect(prisma[model][method]).not.toHaveBeenCalled(); } expect(prisma.auditLog.create).not.toHaveBeenCalled(); if (!bootstrap) expect(prisma.projectClosureChecklistItem.createMany).not.toHaveBeenCalled(); } function expectProjectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { ...details, reasonCode } }); } const projectInputOperations = [ ["POST", "", "create_input_invalid"], ["PATCH", `/${PROJECT_ID}`, "update_input_invalid"], ...[["members", "member"], ["milestones", "milestone"], ["tasks", "task"], ["risks", "risk"], ["communication-logs", "communication_log"]].flatMap(([path, reason]) => [["POST", `/${PROJECT_ID}/${path}`, `${reason}_create_input_invalid`], ["PATCH", `/${PROJECT_ID}/${path}/${ERROR_CHILD_ID}`, `${reason}_update_input_invalid`]]), ["PATCH", `/${PROJECT_ID}/closure-checklist/${ERROR_CHILD_ID}`, "closure_checklist_update_input_invalid"], ["POST", `/${PROJECT_ID}/deliveries`, "delivery_create_input_invalid"], ]; it.each(projectInputOperations)("explains %s %s invalid input without target mutations", async (method, suffix, reason) => { const { app, prisma } = await errorContext(); const response = await app.inject(errorRequest(method, suffix, {})); expectProjectError(response, 400, `project.${reason}`, { formErrors: expect.any(Array), fieldErrors: expect.any(Object) }); noProjectMutations(prisma); }); it.each([ ["/bad", "id", "id_invalid"], ...[["members", "memberId", "member"], ["milestones", "milestoneId", "milestone"], ["tasks", "taskId", "task"], ["risks", "riskId", "risk"], ["communication-logs", "logId", "communication_log"], ["closure-checklist", "itemId", "closure_checklist"]].map(([path, key, reason]) => [`/${PROJECT_ID}/${path}/bad`, key, `${reason}_id_invalid`]), ])("explains invalid project reference %s", async (suffix, key, reason) => { const { app, prisma } = await errorContext(); expectProjectError(await app.inject(errorRequest("PATCH", suffix, {})), 400, `project.${reason}`, { [key]: "invalid_uuid" }); noProjectMutations(prisma); }); it.each([ ["", "project_domain_v1", "feature_disabled", "projectDomainEnabled"], [`/${PROJECT_ID}/weekly-reports`, "project_linking_v1", "linking_disabled", "projectLinkingEnabled"], [`/${PROJECT_ID}/communication-logs`, "project_communication_log_v1", "communication_disabled", "projectCommunicationLogEnabled"], [`/${PROJECT_ID}/closure-checklist`, "project_closure_checklist_v1", "closure_disabled", "projectClosureChecklistEnabled"], [`/${PROJECT_ID}/document-refs`, "confidential_doc_refs_v1", "document_refs_disabled", "confidentialDocRefsEnabled"], ].flatMap((row) => [false, true].map((envOff) => [...row, envOff])))("preserves %s feature gate %s before business access", async (suffix, flag, reason, configKey, envOff) => { const { app, prisma } = await errorContext(); if (envOff) app.config[configKey] = false; else prisma.featureFlag.findUnique.mockImplementation(async ({ where }) => ({ ...buildFeatureFlagRecord(where.flag_key), override_mode: "force_off" })); expectProjectError(await app.inject(errorRequest("GET", suffix)), 409, `project.${reason}`, { featureFlag: `${flag}_disabled`, reason: envOff ? "env_hard_off" : "flag_force_off" }); expect(prisma.project.findUnique).not.toHaveBeenCalled(); expect(prisma.project.findMany).not.toHaveBeenCalled(); noProjectMutations(prisma); if (envOff) expect(prisma.featureFlag.findUnique).not.toHaveBeenCalled(); }); it("explains invalid project query after the feature gate", async () => { const { app, prisma } = await errorContext(); expectProjectError(await app.inject(errorRequest("GET", "?mine=invalid")), 400, "project.query_invalid", { fieldErrors: { mine: expect.any(Array) } }); expect(prisma.project.findMany).not.toHaveBeenCalled(); noProjectMutations(prisma); }); it.each(["status", "clear-note"])("keeps waived note guard on %s", async (mode) => { const { app, prisma, state } = await errorContext(); if (mode === "clear-note") { state.closureChecklistItems[0].status = "waived"; state.closureChecklistItems[0].note = "合成理由"; } expectProjectError(await app.inject(errorRequest("PATCH", `/${PROJECT_ID}/closure-checklist/${ERROR_CHILD_ID}`, mode === "status" ? { status: "waived", note: " " } : { note: null })), 400, "project.waiver_note_required", { note: "waived_requires_note" }); noProjectMutations(prisma); }); it.each(["invalid-id", "invalid-body"])("documents existing checklist bootstrap before %s validation", async (mode) => { const { app, prisma, state } = await errorContext({ emptyChecklist: true }); const firstId = "99999999-9999-4999-8999-000000000100"; const response = await app.inject(errorRequest("PATCH", `/${PROJECT_ID}/closure-checklist/${mode === "invalid-id" ? "bad" : firstId}`, {})); expectProjectError(response, 400, `project.closure_checklist_${mode === "invalid-id" ? "id_invalid" : "update_input_invalid"}`, {}); expect(prisma.projectClosureChecklistItem.createMany).toHaveBeenCalledTimes(1); expect(state.closureChecklistItems).toHaveLength(6); noProjectMutations(prisma, { bootstrap: true }); }); it.each(projectInputOperations.filter(([,suffix]) => !suffix.includes("closure-checklist")))("keeps permission before %s %s validation", async (method, suffix) => { const { app, prisma } = await errorContext({ employee: { id: OUTSIDER_ID, permissionCodes: [] } }); const response = await app.inject(errorRequest(method, suffix, {})); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); noProjectMutations(prisma); }); it.each([ ["members", "projectMember", { employeeId: MEMBER_ID }, { role: "observer" }], ["milestones", "projectMilestone", { title: "合成里程碑" }, { title: "合成修改" }], ["tasks", "projectTask", { title: "合成任务" }, { priority: "high" }], ["risks", "projectRisk", { title: "合成风险", detail: "合成说明" }, { level: "high" }], ].flatMap((row) => ["POST", "PATCH"].map((method) => [...row, method])))("preserves valid %s %s operation", async (path, model, createPayload, patchPayload, method) => { const { app, prisma } = await errorContext(); const response = await app.inject(errorRequest(method, `/${PROJECT_ID}/${path}${method === "PATCH" ? `/${ERROR_CHILD_ID}` : ""}`, method === "POST" ? createPayload : patchPayload)); expect(response.statusCode).toBe(method === "POST" ? 201 : 200); expect(prisma[model][method === "PATCH" ? "update" : model === "projectMember" ? "upsert" : "create"]).toHaveBeenCalledTimes(1); }); it("preserves valid project create and delivery create", async () => { const { app, prisma } = await errorContext(); const created = await app.inject(errorRequest("POST", "", { projectNo: "SYNTHETIC", name: "合成项目", clientName: "合成客户" })); expect(created.statusCode).toBe(201); expect(prisma.project.create).toHaveBeenCalledTimes(1); const delivery = await app.inject(errorRequest("POST", `/${PROJECT_ID}/deliveries`, { title: "合成交付", recordType: "handover", summary: "合成说明", occurredAt: "2026-01-01T00:00:00Z" })); expect(delivery.statusCode).toBe(201); expect(prisma.projectDeliveryRecord.create).toHaveBeenCalledTimes(1); }); it("preserves valid waived note and completion metadata", async () => { const { app, prisma } = await errorContext(); const response = await app.inject(errorRequest("PATCH", `/${PROJECT_ID}/closure-checklist/${ERROR_CHILD_ID}`, { status: "waived", note: "合成理由" })); expect(response.statusCode).toBe(200); expect(prisma.projectClosureChecklistItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: { status: "waived", note: "合成理由", completed_at: expect.any(Date), completed_by: OWNER_ID } })); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); }); it("allocates project numbers inside creation and reserves formal matching codes", async () => { const { app, prisma } = await errorContext(); enableNumbering(prisma); const response = await app.inject(errorRequest("POST", "", { name: "合成年度项目", clientName: "合成客户" })); expect(response.statusCode).toBe(201); expect(prisma.project.create.mock.calls[0][0].data.project_no).toMatch(/^PRJ-\d{4}-0001$/); const formal = await app.inject(errorRequest("POST", "", { projectNo: "PRJ-2026-0050", name: "合成手工号", clientName: "合成客户" })); expect(formal.statusCode).toBe(201); expect(prisma.project.create.mock.calls[1][0].data.project_no).toBe("PRJ-2026-0050"); expect(prisma.businessNumberCounter.upsert.mock.calls.at(-1)[0].update.current_number).toBe(50n); });