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 = { 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) }; }), }, }; 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(); }); });