diff --git a/packages/api/src/__tests__/comment-router.test.ts b/packages/api/src/__tests__/comment-router.test.ts new file mode 100644 index 0000000..010daa8 --- /dev/null +++ b/packages/api/src/__tests__/comment-router.test.ts @@ -0,0 +1,398 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Hoisted mocks – must be declared before any imports that depend on them +// --------------------------------------------------------------------------- + +const { assertCommentEntityAccess, createNotification, createAuditEntry } = vi.hoisted(() => ({ + assertCommentEntityAccess: vi.fn(), + createNotification: vi.fn().mockResolvedValue({}), + createAuditEntry: vi.fn(), +})); + +vi.mock("../lib/comment-entity-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, assertCommentEntityAccess }; +}); + +vi.mock("../lib/create-notification.js", () => ({ createNotification })); + +vi.mock("../lib/audit.js", () => ({ createAuditEntry })); + +// stripHtml is a pure transform – pass through to keep assertions readable +vi.mock("../lib/strip-html.js", () => ({ stripHtml: (s: string) => s })); + +// comment-support helpers – mock the ones with non-trivial side-effects +const { + parseCommentMentions, + commentBelongsToEntity, + assertCommentManageableByActor, + buildCommentCreateData, +} = vi.hoisted(() => ({ + parseCommentMentions: vi.fn().mockReturnValue([]), + commentBelongsToEntity: vi.fn().mockReturnValue(true), + assertCommentManageableByActor: vi.fn(), + buildCommentCreateData: vi.fn( + (input: { + entityType: string; + entityId: string; + parentId?: string; + authorId: string; + body: string; + mentions: string[]; + }) => ({ + entityType: input.entityType, + entityId: input.entityId, + ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), + authorId: input.authorId, + body: input.body, + mentions: input.mentions, + }), + ), +})); + +vi.mock("../router/comment-support.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + parseCommentMentions, + commentBelongsToEntity, + assertCommentManageableByActor, + buildCommentCreateData, + }; +}); + +// --------------------------------------------------------------------------- +// Imports – must come AFTER vi.mock calls +// --------------------------------------------------------------------------- + +import { + countComments, + createComment, + deleteComment, + listCommentMentionCandidates, + listComments, + resolveComment, +} from "../router/comment-procedure-support.js"; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeDb(overrides: Record = {}) { + return { + comment: { + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn().mockResolvedValue({ + id: "comment_1", + authorId: "user_1", + entityType: "estimate", + entityId: "est_1", + }), + count: vi.fn().mockResolvedValue(3), + create: vi.fn().mockResolvedValue({ + id: "comment_1", + body: "Hello", + author: { id: "user_1", name: "Alice", email: "alice@example.com", image: null }, + }), + update: vi.fn().mockResolvedValue({ + id: "comment_1", + resolved: true, + author: { id: "user_1", name: "Alice", email: "alice@example.com", image: null }, + }), + deleteMany: vi.fn().mockResolvedValue({ count: 1 }), + delete: vi.fn().mockResolvedValue(undefined), + ...overrides, + }, + }; +} + +function makeCtx( + dbOverrides: Record = {}, + ctxOverrides: Record = {}, +) { + return { + db: makeDb(dbOverrides), + dbUser: { id: "user_1", systemRole: "ADMIN" }, + roleDefaults: null, + ...ctxOverrides, + }; +} + +const ENTITY_INPUT = { entityType: "estimate" as const, entityId: "est_1" }; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("comment router – procedure support", () => { + beforeEach(() => { + vi.clearAllMocks(); + assertCommentEntityAccess.mockResolvedValue({ + buildLink: (_id: string) => "/estimates/est_1?tab=comments", + listMentionCandidates: vi.fn().mockResolvedValue([]), + }); + commentBelongsToEntity.mockReturnValue(true); + assertCommentManageableByActor.mockReturnValue(undefined); + parseCommentMentions.mockReturnValue([]); + createNotification.mockResolvedValue({}); + createAuditEntry.mockResolvedValue(undefined); + }); + + // ------------------------------------------------------------------------- + // listComments + // ------------------------------------------------------------------------- + + describe("listComments", () => { + it("queries with parentId:null and orders by createdAt asc", async () => { + const ctx = makeCtx(); + await listComments(ctx as never, ENTITY_INPUT); + + expect(ctx.db.comment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { entityType: "estimate", entityId: "est_1", parentId: null }, + orderBy: { createdAt: "asc" }, + }), + ); + }); + + it("calls assertCommentEntityAccess before touching the database", async () => { + const ctx = makeCtx(); + const callOrder: string[] = []; + assertCommentEntityAccess.mockImplementation(async () => { + callOrder.push("access"); + return { buildLink: () => "/link", listMentionCandidates: vi.fn() }; + }); + (ctx.db.comment.findMany as ReturnType).mockImplementation(async () => { + callOrder.push("db"); + return []; + }); + + await listComments(ctx as never, ENTITY_INPUT); + + expect(callOrder).toEqual(["access", "db"]); + }); + }); + + // ------------------------------------------------------------------------- + // countComments + // ------------------------------------------------------------------------- + + describe("countComments", () => { + it("returns the count from db.comment.count", async () => { + const ctx = makeCtx(); + const result = await countComments(ctx as never, ENTITY_INPUT); + expect(result).toBe(3); + }); + + it("calls assertCommentEntityAccess with entityType and entityId", async () => { + const ctx = makeCtx(); + await countComments(ctx as never, ENTITY_INPUT); + expect(assertCommentEntityAccess).toHaveBeenCalledWith(ctx, "estimate", "est_1"); + }); + }); + + // ------------------------------------------------------------------------- + // listCommentMentionCandidates + // ------------------------------------------------------------------------- + + describe("listCommentMentionCandidates", () => { + it("delegates to policy.listMentionCandidates with the normalised query", async () => { + const listMentionCandidates = vi + .fn() + .mockResolvedValue([{ id: "user_2", name: "Bob", email: "bob@example.com" }]); + assertCommentEntityAccess.mockResolvedValue({ + buildLink: () => "/link", + listMentionCandidates, + }); + const ctx = makeCtx(); + + const result = await listCommentMentionCandidates(ctx as never, { + ...ENTITY_INPUT, + query: "bo", + }); + + expect(result).toEqual([{ id: "user_2", name: "Bob", email: "bob@example.com" }]); + expect(listMentionCandidates).toHaveBeenCalledWith(ctx, "est_1", "bo"); + }); + + it("passes undefined when query is an empty string", async () => { + const listMentionCandidates = vi.fn().mockResolvedValue([]); + assertCommentEntityAccess.mockResolvedValue({ + buildLink: () => "/link", + listMentionCandidates, + }); + const ctx = makeCtx(); + + await listCommentMentionCandidates(ctx as never, { ...ENTITY_INPUT, query: "" }); + + expect(listMentionCandidates).toHaveBeenCalledWith(ctx, "est_1", undefined); + }); + }); + + // ------------------------------------------------------------------------- + // createComment + // ------------------------------------------------------------------------- + + describe("createComment", () => { + it("creates a comment with the correct data shape", async () => { + const ctx = makeCtx(); + const result = await createComment(ctx as never, { + ...ENTITY_INPUT, + body: "Nice work", + }); + + expect(result.id).toBe("comment_1"); + expect(ctx.db.comment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + entityType: "estimate", + entityId: "est_1", + authorId: "user_1", + body: "Nice work", + mentions: [], + }), + }), + ); + }); + + it("throws UNAUTHORIZED when dbUser is absent", async () => { + const ctx = makeCtx({}, { dbUser: null }); + + await expect( + createComment(ctx as never, { ...ENTITY_INPUT, body: "Hello" }), + ).rejects.toThrowError(new TRPCError({ code: "UNAUTHORIZED" })); + + expect(ctx.db.comment.create).not.toHaveBeenCalled(); + }); + + it("throws NOT_FOUND when parentId references a missing comment", async () => { + const ctx = makeCtx({ findUnique: vi.fn().mockResolvedValue(null) }); + + await expect( + createComment(ctx as never, { + ...ENTITY_INPUT, + parentId: "comment_missing", + body: "Reply", + }), + ).rejects.toThrowError( + new TRPCError({ code: "NOT_FOUND", message: "Parent comment not found" }), + ); + + expect(ctx.db.comment.create).not.toHaveBeenCalled(); + }); + + it("throws BAD_REQUEST when parent comment belongs to a different entity", async () => { + commentBelongsToEntity.mockReturnValue(false); + const ctx = makeCtx({ + findUnique: vi.fn().mockResolvedValue({ + id: "comment_parent", + entityType: "estimate", + entityId: "est_other", + }), + }); + + await expect( + createComment(ctx as never, { + ...ENTITY_INPUT, + parentId: "comment_parent", + body: "Reply", + }), + ).rejects.toThrowError( + new TRPCError({ + code: "BAD_REQUEST", + message: "Parent comment does not belong to the requested entity", + }), + ); + + expect(ctx.db.comment.create).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // resolveComment + // ------------------------------------------------------------------------- + + describe("resolveComment", () => { + it("resolves a comment by setting resolved:true", async () => { + const ctx = makeCtx(); + const result = await resolveComment(ctx as never, { id: "comment_1", resolved: true }); + + expect(result.resolved).toBe(true); + expect(ctx.db.comment.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "comment_1" }, + data: { resolved: true }, + }), + ); + }); + + it("unresolves a comment by setting resolved:false", async () => { + makeCtx().db.comment.update as ReturnType; + const ctx = makeCtx({ + update: vi.fn().mockResolvedValue({ + id: "comment_1", + resolved: false, + author: { id: "user_1", name: "Alice", email: "alice@example.com", image: null }, + }), + }); + + const result = await resolveComment(ctx as never, { id: "comment_1", resolved: false }); + + expect(result.resolved).toBe(false); + expect(ctx.db.comment.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { resolved: false } }), + ); + }); + + it("throws NOT_FOUND when comment does not exist", async () => { + const ctx = makeCtx({ findUnique: vi.fn().mockResolvedValue(null) }); + + await expect( + resolveComment(ctx as never, { id: "comment_missing", resolved: true }), + ).rejects.toThrowError(new TRPCError({ code: "NOT_FOUND", message: "Comment not found" })); + + expect(ctx.db.comment.update).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // deleteComment + // ------------------------------------------------------------------------- + + describe("deleteComment", () => { + it("deletes children via deleteMany then the comment itself", async () => { + const ctx = makeCtx(); + await deleteComment(ctx as never, { id: "comment_1" }); + + expect(ctx.db.comment.deleteMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { parentId: "comment_1" } }), + ); + expect(ctx.db.comment.delete).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "comment_1" } }), + ); + }); + + it("throws UNAUTHORIZED when dbUser is absent", async () => { + const ctx = makeCtx({}, { dbUser: null }); + + await expect(deleteComment(ctx as never, { id: "comment_1" })).rejects.toThrowError( + new TRPCError({ code: "UNAUTHORIZED" }), + ); + + expect(ctx.db.comment.delete).not.toHaveBeenCalled(); + }); + + it("throws NOT_FOUND when comment does not exist", async () => { + const ctx = makeCtx({ findUnique: vi.fn().mockResolvedValue(null) }); + + await expect(deleteComment(ctx as never, { id: "comment_missing" })).rejects.toThrowError( + new TRPCError({ code: "NOT_FOUND", message: "Comment not found" }), + ); + + expect(ctx.db.comment.delete).not.toHaveBeenCalled(); + expect(ctx.db.comment.deleteMany).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/api/src/__tests__/dispo-router.test.ts b/packages/api/src/__tests__/dispo-router.test.ts index a6b886e..d84f268 100644 --- a/packages/api/src/__tests__/dispo-router.test.ts +++ b/packages/api/src/__tests__/dispo-router.test.ts @@ -1,172 +1,459 @@ -import { ImportBatchStatus } from "@capakraken/db"; -import { SystemRole } from "@capakraken/shared"; +import { DispoStagedRecordType, ImportBatchStatus, StagedRecordStatus } from "@capakraken/db"; +import { TRPCError } from "@trpc/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { - assessDispoImportReadiness, - stageDispoImportBatch, -} = vi.hoisted(() => ({ - assessDispoImportReadiness: vi.fn(), - stageDispoImportBatch: vi.fn(), -})); - -const { createAuditEntry } = vi.hoisted(() => ({ +vi.mock("../lib/audit.js", () => ({ createAuditEntry: vi.fn(), })); vi.mock("@capakraken/application", () => ({ - assessDispoImportReadiness, - stageDispoImportBatch, + commitDispoImportBatch: vi.fn().mockResolvedValue({ resources: 5, projects: 3 }), })); -vi.mock("../lib/audit.js", () => ({ - createAuditEntry, -})); +import { createAuditEntry } from "../lib/audit.js"; +import { commitDispoImportBatch } from "@capakraken/application"; +import { + assertImportBatchCancelable, + buildCancelledImportBatchUpdateData, + buildDispoImportCommitAuditSummary, + buildResolvedStagedRecordUpdateData, + resolveDispoStagedRecordStatus, + resolveDispoStagedRecordStoreKey, +} from "../router/dispo-management-support.js"; +import { + cancelImportBatch, + commitImportBatch, + resolveStagedRecord, +} from "../router/dispo-management.js"; +import { + getImportBatch, + listImportBatches, + listStagedAssignments, + listStagedResources, + listStagedUnresolvedRecords, +} from "../router/dispo-read.js"; -import { dispoRouter } from "../router/dispo.js"; -import { createCallerFactory } from "../trpc.js"; +// ─── dispo-management-support pure functions ────────────────────────────────── -const createCaller = createCallerFactory(dispoRouter); +describe("assertImportBatchCancelable", () => { + it("does not throw for non-terminal statuses", () => { + expect(() => + assertImportBatchCancelable({ id: "batch_1", status: ImportBatchStatus.STAGED }), + ).not.toThrow(); -function createDispoCaller( - db: Record, - options: { role?: SystemRole } = {}, -) { - const { role = SystemRole.ADMIN } = options; - - return createCaller({ - session: { - user: { email: "user@example.com", name: "User", image: null }, - expires: "2099-01-01T00:00:00.000Z", - }, - db: db as never, - dbUser: { - id: role === SystemRole.ADMIN ? "user_admin" : "user_1", - systemRole: role, - permissionOverrides: null, - }, + expect(() => + assertImportBatchCancelable({ id: "batch_1", status: ImportBatchStatus.REVIEW_READY }), + ).not.toThrow(); }); -} -describe("dispo router", () => { + it("throws BAD_REQUEST for COMMITTED status", () => { + expect(() => + assertImportBatchCancelable({ id: "batch_1", status: ImportBatchStatus.COMMITTED }), + ).toThrow( + new TRPCError({ + code: "BAD_REQUEST", + message: 'Cannot cancel batch in status "COMMITTED"', + }), + ); + }); + + it("throws BAD_REQUEST for CANCELLED status", () => { + expect(() => + assertImportBatchCancelable({ id: "batch_1", status: ImportBatchStatus.CANCELLED }), + ).toThrow( + new TRPCError({ + code: "BAD_REQUEST", + message: 'Cannot cancel batch in status "CANCELLED"', + }), + ); + }); +}); + +describe("resolveDispoStagedRecordStatus", () => { + it("maps APPROVE to APPROVED and REJECT/SKIP to REJECTED", () => { + expect(resolveDispoStagedRecordStatus("APPROVE")).toBe(StagedRecordStatus.APPROVED); + expect(resolveDispoStagedRecordStatus("REJECT")).toBe(StagedRecordStatus.REJECTED); + expect(resolveDispoStagedRecordStatus("SKIP")).toBe(StagedRecordStatus.REJECTED); + }); +}); + +describe("resolveDispoStagedRecordStoreKey", () => { + it("maps every DispoStagedRecordType enum value to the correct Prisma store key", () => { + expect(resolveDispoStagedRecordStoreKey(DispoStagedRecordType.RESOURCE)).toBe("stagedResource"); + expect(resolveDispoStagedRecordStoreKey(DispoStagedRecordType.CLIENT)).toBe("stagedClient"); + expect(resolveDispoStagedRecordStoreKey(DispoStagedRecordType.PROJECT)).toBe("stagedProject"); + expect(resolveDispoStagedRecordStoreKey(DispoStagedRecordType.ASSIGNMENT)).toBe( + "stagedAssignment", + ); + expect(resolveDispoStagedRecordStoreKey(DispoStagedRecordType.VACATION)).toBe("stagedVacation"); + expect(resolveDispoStagedRecordStoreKey(DispoStagedRecordType.AVAILABILITY_RULE)).toBe( + "stagedAvailabilityRule", + ); + expect(resolveDispoStagedRecordStoreKey(DispoStagedRecordType.UNRESOLVED)).toBe( + "stagedUnresolvedRecord", + ); + }); +}); + +describe("buildDispoImportCommitAuditSummary", () => { + it("produces the expected summary string from counts", () => { + expect(buildDispoImportCommitAuditSummary({ resources: 5, projects: 3 })).toBe( + 'Committed import batch ({"resources":5,"projects":3})', + ); + }); +}); + +describe("buildCancelledImportBatchUpdateData / buildResolvedStagedRecordUpdateData", () => { + it("returns the correct update payloads", () => { + expect(buildCancelledImportBatchUpdateData()).toEqual({ + status: ImportBatchStatus.CANCELLED, + }); + + expect(buildResolvedStagedRecordUpdateData("APPROVE")).toEqual({ + status: StagedRecordStatus.APPROVED, + }); + + expect(buildResolvedStagedRecordUpdateData("SKIP")).toEqual({ + status: StagedRecordStatus.REJECTED, + }); + }); +}); + +// ─── listImportBatches ──────────────────────────────────────────────────────── + +describe("listImportBatches", () => { + it("returns paginated results and sets nextCursor when more items exist", async () => { + const items = [ + { id: "batch_3" }, + { id: "batch_2" }, + { id: "batch_1" }, // extra item beyond limit + ]; + const findMany = vi.fn().mockResolvedValue(items); + const db = { importBatch: { findMany } }; + + const result = await listImportBatches(db, { limit: 2 }); + + expect(findMany).toHaveBeenCalledWith({ + where: {}, + orderBy: { createdAt: "desc" }, + take: 3, + }); + expect(result.items).toHaveLength(2); + expect(result.nextCursor).toBe("batch_1"); + }); + + it("applies optional status filter to the where clause", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const db = { importBatch: { findMany } }; + + await listImportBatches(db, { limit: 10, status: ImportBatchStatus.STAGED }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { status: ImportBatchStatus.STAGED }, + }), + ); + }); +}); + +// ─── getImportBatch ─────────────────────────────────────────────────────────── + +describe("getImportBatch", () => { + function buildCountDb(batchValue: unknown) { + return { + importBatch: { findUnique: vi.fn().mockResolvedValue(batchValue) }, + stagedResource: { count: vi.fn().mockResolvedValue(10) }, + stagedClient: { count: vi.fn().mockResolvedValue(2) }, + stagedProject: { count: vi.fn().mockResolvedValue(4) }, + stagedAssignment: { count: vi.fn().mockResolvedValue(20) }, + stagedVacation: { count: vi.fn().mockResolvedValue(1) }, + stagedAvailabilityRule: { count: vi.fn().mockResolvedValue(3) }, + stagedUnresolvedRecord: { count: vi.fn().mockResolvedValue(0) }, + }; + } + + it("returns the batch enriched with counts from all staged tables", async () => { + const batch = { id: "batch_1", status: ImportBatchStatus.STAGED }; + const db = buildCountDb(batch); + + const result = await getImportBatch(db, "batch_1"); + + expect(result).toMatchObject({ + id: "batch_1", + counts: { + resourceCount: 10, + clientCount: 2, + projectCount: 4, + assignmentCount: 20, + vacationCount: 1, + availabilityRuleCount: 3, + unresolvedCount: 0, + }, + }); + // Each count query is scoped to the batch id + expect(db.stagedResource.count).toHaveBeenCalledWith({ where: { importBatchId: "batch_1" } }); + expect(db.stagedUnresolvedRecord.count).toHaveBeenCalledWith({ + where: { importBatchId: "batch_1" }, + }); + }); + + it("throws NOT_FOUND when the batch does not exist", async () => { + const db = buildCountDb(null); + + await expect(getImportBatch(db, "missing_batch")).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + // Counts should not be fetched for a missing batch + expect(db.stagedResource.count).not.toHaveBeenCalled(); + }); +}); + +// ─── listStagedResources ────────────────────────────────────────────────────── + +describe("listStagedResources", () => { + it("paginates results using the cursor pattern", async () => { + const findMany = vi.fn().mockResolvedValue([ + { id: "res_2" }, + { id: "res_1" }, // extra item that triggers cursor + ]); + const db = { stagedResource: { findMany } }; + + const result = await listStagedResources(db, { + importBatchId: "batch_1", + limit: 1, + }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { importBatchId: "batch_1" }, + take: 2, + }), + ); + expect(result.items).toHaveLength(1); + expect(result.nextCursor).toBe("res_1"); + }); + + it("passes cursor and status filter through to findMany", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const db = { stagedResource: { findMany } }; + + await listStagedResources(db, { + importBatchId: "batch_1", + limit: 25, + cursor: "res_10", + status: StagedRecordStatus.APPROVED, + }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { importBatchId: "batch_1", status: StagedRecordStatus.APPROVED }, + cursor: { id: "res_10" }, + skip: 1, + }), + ); + }); +}); + +// ─── cancelImportBatch ─────────────────────────────────────────────────────── + +describe("cancelImportBatch", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("requires admin access for dispo import procedures", async () => { - const findMany = vi.fn(); - const caller = createDispoCaller( - { importBatch: { findMany } }, - { role: SystemRole.USER }, - ); + it("cancels a batch in a non-terminal status and creates an audit entry", async () => { + const batch = { id: "batch_1", status: ImportBatchStatus.STAGED }; + const cancelled = { id: "batch_1", status: ImportBatchStatus.CANCELLED }; + const findUnique = vi.fn().mockResolvedValue(batch); + const update = vi.fn().mockResolvedValue(cancelled); + const db = { importBatch: { findUnique, update } }; - await expect(caller.listImportBatches({ limit: 10 })).rejects.toMatchObject({ - code: "FORBIDDEN", - message: "Admin role required", - }); - await expect( - caller.stageImportBatch({ - chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", - planningWorkbookPath: "/tmp/planning.xlsx", - referenceWorkbookPath: "/tmp/reference.xlsx", - }), - ).rejects.toMatchObject({ - code: "FORBIDDEN", - message: "Admin role required", - }); + const result = await cancelImportBatch(db as never, { id: "batch_1", userId: "user_1" }); - expect(findMany).not.toHaveBeenCalled(); - expect(stageDispoImportBatch).not.toHaveBeenCalled(); - }); - - it("lists import batches through the thin router wiring", async () => { - const findMany = vi.fn().mockResolvedValue([ - { id: "batch_2", createdAt: new Date("2026-03-29T00:00:00.000Z") }, - { id: "batch_1", createdAt: new Date("2026-03-28T00:00:00.000Z") }, - ]); - - const caller = createDispoCaller({ - importBatch: { findMany }, - }); - const result = await caller.listImportBatches({ - limit: 1, - status: ImportBatchStatus.STAGED, - }); - - expect(findMany).toHaveBeenCalledWith({ - where: { status: ImportBatchStatus.STAGED }, - orderBy: { createdAt: "desc" }, - take: 2, - }); - expect(result).toEqual({ - items: [{ id: "batch_2", createdAt: new Date("2026-03-29T00:00:00.000Z") }], - nextCursor: "batch_1", - }); - }); - - it("stages, validates, and cancels import batches through the admin router", async () => { - stageDispoImportBatch.mockResolvedValue({ id: "batch_1", status: ImportBatchStatus.STAGED }); - assessDispoImportReadiness.mockResolvedValue({ ready: true, issues: [] }); - const findUnique = vi.fn().mockResolvedValue({ - id: "batch_1", - status: ImportBatchStatus.STAGED, - }); - const update = vi.fn().mockResolvedValue({ - id: "batch_1", - status: ImportBatchStatus.CANCELLED, - }); - - const caller = createDispoCaller({ - importBatch: { findUnique, update }, - }); - - const staged = await caller.stageImportBatch({ - chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", - notes: "overnight patch", - planningWorkbookPath: "/tmp/planning.xlsx", - referenceWorkbookPath: "/tmp/reference.xlsx", - }); - const validated = await caller.validateImportBatch({ - chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", - planningWorkbookPath: "/tmp/planning.xlsx", - referenceWorkbookPath: "/tmp/reference.xlsx", - }); - const cancelled = await caller.cancelImportBatch({ id: "batch_1" }); - - expect(stageDispoImportBatch).toHaveBeenCalledWith( - expect.objectContaining({ importBatch: { findUnique, update } }), - { - chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", - notes: "overnight patch", - planningWorkbookPath: "/tmp/planning.xlsx", - referenceWorkbookPath: "/tmp/reference.xlsx", - }, - ); - expect(assessDispoImportReadiness).toHaveBeenCalledWith({ - chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", - planningWorkbookPath: "/tmp/planning.xlsx", - referenceWorkbookPath: "/tmp/reference.xlsx", - }); expect(findUnique).toHaveBeenCalledWith({ where: { id: "batch_1" }, select: { id: true, status: true }, }); expect(update).toHaveBeenCalledWith({ where: { id: "batch_1" }, - data: { - status: ImportBatchStatus.CANCELLED, - }, + data: { status: ImportBatchStatus.CANCELLED }, }); - expect(staged).toEqual({ id: "batch_1", status: ImportBatchStatus.STAGED }); - expect(validated).toEqual({ ready: true, issues: [] }); - expect(cancelled).toEqual({ id: "batch_1", status: ImportBatchStatus.CANCELLED }); + expect(result).toEqual(cancelled); + // Audit is fire-and-forget; verify it was initiated expect(createAuditEntry).toHaveBeenCalledWith( expect.objectContaining({ entityType: "ImportBatch", entityId: "batch_1", + action: "UPDATE", summary: "Cancelled import batch", + userId: "user_1", + }), + ); + }); + + it("throws NOT_FOUND when the batch does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const db = { importBatch: { findUnique, update: vi.fn() } }; + + await expect(cancelImportBatch(db as never, { id: "missing_batch" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + + expect(db.importBatch.update).not.toHaveBeenCalled(); + }); + + it("throws BAD_REQUEST when the batch is already in a terminal status", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "batch_1", + status: ImportBatchStatus.COMMITTED, + }); + const update = vi.fn(); + const db = { importBatch: { findUnique, update } }; + + await expect(cancelImportBatch(db as never, { id: "batch_1" })).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + + expect(update).not.toHaveBeenCalled(); + }); +}); + +// ─── resolveStagedRecord ────────────────────────────────────────────────────── + +describe("resolveStagedRecord", () => { + it("routes to the correct store and sets APPROVED status for APPROVE action", async () => { + const update = vi.fn().mockResolvedValue({ id: "rec_1", status: StagedRecordStatus.APPROVED }); + const db = { + stagedResource: { update: vi.fn() }, + stagedClient: { update: vi.fn() }, + stagedProject: { update: vi.fn() }, + stagedAssignment: { update }, + stagedVacation: { update: vi.fn() }, + stagedAvailabilityRule: { update: vi.fn() }, + stagedUnresolvedRecord: { update: vi.fn() }, + }; + + const result = await resolveStagedRecord(db, { + id: "rec_1", + recordType: DispoStagedRecordType.ASSIGNMENT, + action: "APPROVE", + }); + + expect(update).toHaveBeenCalledWith({ + where: { id: "rec_1" }, + data: { status: StagedRecordStatus.APPROVED }, + }); + expect(result).toEqual({ id: "rec_1", status: StagedRecordStatus.APPROVED }); + // No other store should have been touched + expect(db.stagedResource.update).not.toHaveBeenCalled(); + }); + + it("routes to stagedUnresolvedRecord and sets REJECTED status for SKIP action", async () => { + const update = vi.fn().mockResolvedValue({ id: "rec_2", status: StagedRecordStatus.REJECTED }); + const db = { + stagedResource: { update: vi.fn() }, + stagedClient: { update: vi.fn() }, + stagedProject: { update: vi.fn() }, + stagedAssignment: { update: vi.fn() }, + stagedVacation: { update: vi.fn() }, + stagedAvailabilityRule: { update: vi.fn() }, + stagedUnresolvedRecord: { update }, + }; + + await resolveStagedRecord(db, { + id: "rec_2", + recordType: DispoStagedRecordType.UNRESOLVED, + action: "SKIP", + }); + + expect(update).toHaveBeenCalledWith({ + where: { id: "rec_2" }, + data: { status: StagedRecordStatus.REJECTED }, + }); + }); +}); + +// ─── commitImportBatch ──────────────────────────────────────────────────────── + +describe("commitImportBatch", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(commitDispoImportBatch).mockResolvedValue({ resources: 5, projects: 3 } as never); + }); + + it("delegates to the application layer with the provided options", async () => { + const db = { marker: "db" }; + + const result = await commitImportBatch(db as never, { + importBatchId: "batch_1", + allowTbdUnresolved: true, + importTbdProjects: false, + }); + + expect(commitDispoImportBatch).toHaveBeenCalledWith(db, { + importBatchId: "batch_1", + allowTbdUnresolved: true, + importTbdProjects: false, + }); + expect(result).toEqual({ resources: 5, projects: 3 }); + }); + + it("creates an audit entry with the commit summary after a successful commit", async () => { + const db = { marker: "db" }; + + await commitImportBatch(db as never, { + importBatchId: "batch_1", + userId: "user_admin", + }); + + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "ImportBatch", + entityId: "batch_1", + action: "IMPORT", + summary: 'Committed import batch ({"resources":5,"projects":3})', userId: "user_admin", }), ); }); }); + +// ─── listStagedAssignments / listStagedUnresolvedRecords (spot-check) ───────── + +describe("listStagedAssignments", () => { + it("filters by resourceExternalId when provided", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const db = { stagedAssignment: { findMany } }; + + await listStagedAssignments(db, { + importBatchId: "batch_1", + limit: 10, + resourceExternalId: "R-007", + }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { importBatchId: "batch_1", resourceExternalId: "R-007" }, + }), + ); + }); +}); + +describe("listStagedUnresolvedRecords", () => { + it("filters by recordType when provided", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const db = { stagedUnresolvedRecord: { findMany } }; + + await listStagedUnresolvedRecords(db, { + importBatchId: "batch_1", + limit: 10, + recordType: DispoStagedRecordType.PROJECT, + }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { importBatchId: "batch_1", recordType: DispoStagedRecordType.PROJECT }, + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/holiday-calendar-catalog-read.test.ts b/packages/api/src/__tests__/holiday-calendar-catalog-read.test.ts new file mode 100644 index 0000000..26e379a --- /dev/null +++ b/packages/api/src/__tests__/holiday-calendar-catalog-read.test.ts @@ -0,0 +1,311 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { createCallerFactory, createTRPCRouter } from "../trpc.js"; +import { holidayCalendarCatalogReadProcedures } from "../router/holiday-calendar-catalog-read.js"; + +// Pass-through so the db mock reaches the procedures unchanged +vi.mock("../router/holiday-calendar-shared.js", () => ({ + asHolidayCalendarDb: (db: unknown) => db, +})); + +// Provide the same shape the real support module exports +vi.mock("../router/holiday-calendar-support.js", () => ({ + holidayCalendarListInclude: { + country: { select: { id: true, code: true, name: true } }, + metroCity: { select: { id: true, name: true } }, + entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, + _count: { select: { entries: true } }, + }, + holidayCalendarDetailInclude: { + country: { select: { id: true, code: true, name: true } }, + metroCity: { select: { id: true, name: true } }, + entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, + }, +})); + +const router = createTRPCRouter(holidayCalendarCatalogReadProcedures); +const createCaller = createCallerFactory(router); + +function createAdminCaller(db: Record) { + return createCaller({ + session: { + user: { email: "admin@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { id: "user_admin", systemRole: SystemRole.ADMIN, permissionOverrides: null }, + }); +} + +function createUserCaller(db: Record) { + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { id: "user_1", systemRole: SystemRole.USER, permissionOverrides: null }, + }); +} + +const mockCalendar = { + id: "cal_1", + name: "German Federal Holidays", + scopeType: "COUNTRY", + stateCode: null, + isActive: true, + priority: 0, + country: { id: "country_de", code: "DE", name: "Germany" }, + metroCity: null, + _count: { entries: 12 }, + entries: [ + { + id: "entry_1", + date: new Date("2026-01-01"), + name: "New Year", + isRecurringAnnual: true, + source: null, + }, + ], +}; + +// ─── listCalendars ──────────────────────────────────────────────────────────── + +describe("listCalendars", () => { + it("returns all active calendars when called with no input", async () => { + const findMany = vi.fn().mockResolvedValue([mockCalendar]); + const caller = createAdminCaller({ holidayCalendar: { findMany } }); + + const result = await caller.listCalendars(); + + expect(result).toEqual([mockCalendar]); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ isActive: true }), + }), + ); + }); + + it("applies countryCode filter uppercased with case-insensitive mode", async () => { + const findMany = vi.fn().mockResolvedValue([mockCalendar]); + const caller = createAdminCaller({ holidayCalendar: { findMany } }); + + await caller.listCalendars({ countryCode: "de" }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + country: { code: { equals: "DE", mode: "insensitive" } }, + }), + }), + ); + }); + + it("includes inactive calendars when includeInactive is true", async () => { + const inactiveCalendar = { ...mockCalendar, id: "cal_inactive", isActive: false }; + const findMany = vi.fn().mockResolvedValue([mockCalendar, inactiveCalendar]); + const caller = createAdminCaller({ holidayCalendar: { findMany } }); + + const result = await caller.listCalendars({ includeInactive: true }); + + expect(result).toHaveLength(2); + // isActive filter must NOT be present in the where clause + const whereArg = findMany.mock.calls[0][0].where; + expect(whereArg).not.toHaveProperty("isActive"); + }); +}); + +// ─── listCalendarsDetail ────────────────────────────────────────────────────── + +describe("listCalendarsDetail", () => { + it("returns count and formatted calendar array", async () => { + const findMany = vi.fn().mockResolvedValue([mockCalendar]); + const caller = createAdminCaller({ holidayCalendar: { findMany } }); + + const result = await caller.listCalendarsDetail(); + + expect(result.count).toBe(1); + expect(result.calendars).toHaveLength(1); + expect(result.calendars[0]).toMatchObject({ + id: "cal_1", + name: "German Federal Holidays", + scopeType: "COUNTRY", + stateCode: null, + isActive: true, + priority: 0, + country: { id: "country_de", code: "DE", name: "Germany" }, + metroCity: null, + entryCount: 12, + }); + }); + + it("formats entry dates as ISO date strings (YYYY-MM-DD)", async () => { + const findMany = vi.fn().mockResolvedValue([mockCalendar]); + const caller = createAdminCaller({ holidayCalendar: { findMany } }); + + const result = await caller.listCalendarsDetail(); + + expect(result.calendars[0].entries[0]).toMatchObject({ + id: "entry_1", + date: "2026-01-01", + name: "New Year", + isRecurringAnnual: true, + source: null, + }); + }); +}); + +// ─── getCalendarByIdentifier ────────────────────────────────────────────────── + +describe("getCalendarByIdentifier", () => { + it("finds a calendar by exact id on the first lookup", async () => { + const findUnique = vi.fn().mockResolvedValue(mockCalendar); + const findFirst = vi.fn(); + const caller = createAdminCaller({ holidayCalendar: { findUnique, findFirst } }); + + const result = await caller.getCalendarByIdentifier({ identifier: "cal_1" }); + + expect(result).toEqual(mockCalendar); + expect(findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: "cal_1" } })); + expect(findFirst).not.toHaveBeenCalled(); + }); + + it("falls back to exact name match when id lookup returns null", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const findFirst = vi.fn().mockResolvedValueOnce(mockCalendar); + const caller = createAdminCaller({ holidayCalendar: { findUnique, findFirst } }); + + const result = await caller.getCalendarByIdentifier({ identifier: "German Federal Holidays" }); + + expect(result).toEqual(mockCalendar); + expect(findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { name: { equals: "German Federal Holidays", mode: "insensitive" } }, + }), + ); + }); + + it("falls back to contains name match when exact name lookup returns null", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const findFirst = vi + .fn() + .mockResolvedValueOnce(null) // exact name miss + .mockResolvedValueOnce(mockCalendar); // contains name hit + const caller = createAdminCaller({ holidayCalendar: { findUnique, findFirst } }); + + const result = await caller.getCalendarByIdentifier({ identifier: "Federal" }); + + expect(result).toEqual(mockCalendar); + expect(findFirst).toHaveBeenCalledTimes(2); + expect(findFirst).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { name: { contains: "Federal", mode: "insensitive" } }, + }), + ); + }); + + it("throws NOT_FOUND when no match is found at all", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const findFirst = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ holidayCalendar: { findUnique, findFirst } }); + + await expect( + caller.getCalendarByIdentifier({ identifier: "does-not-exist" }), + ).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Holiday calendar not found: does-not-exist", + }); + }); +}); + +// ─── getCalendarByIdentifierDetail ──────────────────────────────────────────── + +describe("getCalendarByIdentifierDetail", () => { + it("returns formatted detail for a matching calendar", async () => { + const findUnique = vi.fn().mockResolvedValue(mockCalendar); + const caller = createAdminCaller({ holidayCalendar: { findUnique, findFirst: vi.fn() } }); + + const result = await caller.getCalendarByIdentifierDetail({ identifier: "cal_1" }); + + expect(result).toMatchObject({ + id: "cal_1", + name: "German Federal Holidays", + scopeType: "COUNTRY", + isActive: true, + entryCount: 12, + entries: [expect.objectContaining({ date: "2026-01-01" })], + }); + }); + + it("throws NOT_FOUND when identifier does not match any calendar", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const findFirst = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ holidayCalendar: { findUnique, findFirst } }); + + await expect( + caller.getCalendarByIdentifierDetail({ identifier: "no-such-calendar" }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); +}); + +// ─── getCalendarById ────────────────────────────────────────────────────────── + +describe("getCalendarById", () => { + it("returns the calendar when found by id", async () => { + const findUnique = vi.fn().mockResolvedValue(mockCalendar); + const caller = createAdminCaller({ holidayCalendar: { findUnique } }); + + const result = await caller.getCalendarById({ id: "cal_1" }); + + expect(result).toEqual(mockCalendar); + expect(findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: "cal_1" } })); + }); + + it("throws NOT_FOUND when no calendar exists with the given id", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ holidayCalendar: { findUnique } }); + + await expect(caller.getCalendarById({ id: "cal_missing" })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Holiday calendar not found", + }); + }); +}); + +// ─── Authorization ──────────────────────────────────────────────────────────── + +describe("Authorization", () => { + it("rejects a non-admin user (SystemRole.USER) with FORBIDDEN on all procedures", async () => { + const findMany = vi.fn(); + const findUnique = vi.fn(); + const findFirst = vi.fn(); + const caller = createUserCaller({ holidayCalendar: { findMany, findUnique, findFirst } }); + + await expect(caller.listCalendars()).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + await expect(caller.listCalendarsDetail()).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + await expect(caller.getCalendarByIdentifier({ identifier: "cal_1" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + await expect( + caller.getCalendarByIdentifierDetail({ identifier: "cal_1" }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + await expect(caller.getCalendarById({ id: "cal_1" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + + expect(findMany).not.toHaveBeenCalled(); + expect(findUnique).not.toHaveBeenCalled(); + expect(findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/__tests__/project-lifecycle-router.test.ts b/packages/api/src/__tests__/project-lifecycle-router.test.ts new file mode 100644 index 0000000..59c3624 --- /dev/null +++ b/packages/api/src/__tests__/project-lifecycle-router.test.ts @@ -0,0 +1,429 @@ +import { ProjectStatus, SystemRole } from "@capakraken/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createProjectLifecycleProcedures } from "../router/project-lifecycle.js"; +import { createCallerFactory, createTRPCRouter } from "../trpc.js"; + +// ─── Dependency mocks ───────────────────────────────────────────────────────── + +const deps = { + invalidateDashboardCacheInBackground: vi.fn(), + dispatchProjectWebhookInBackground: vi.fn(), +}; + +const procedures = createProjectLifecycleProcedures(deps); +const router = createTRPCRouter(procedures); +const createCaller = createCallerFactory(router); + +// ─── Caller factories ───────────────────────────────────────────────────────── + +function createManagerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "mgr@example.com", name: "Manager", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { id: "user_mgr", systemRole: SystemRole.MANAGER, permissionOverrides: null }, + }); +} + +function createAdminCaller(db: Record) { + return createCaller({ + session: { + user: { email: "admin@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { id: "user_admin", systemRole: SystemRole.ADMIN, permissionOverrides: null }, + }); +} + +// ─── Shared project fixture ─────────────────────────────────────────────────── + +const baseProject = { + id: "proj_1", + name: "Alpha Project", + shortCode: "ALF-001", + status: ProjectStatus.DRAFT, +}; + +// ─── Transaction mock helpers ───────────────────────────────────────────────── + +function makeTxMock() { + return { + assignment: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + demandRequirement: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + calculationRule: { updateMany: vi.fn().mockResolvedValue({ count: 0 }) }, + project: { + update: vi.fn(), + delete: vi.fn().mockResolvedValue(undefined), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + auditLog: { create: vi.fn().mockResolvedValue(undefined) }, + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("project-lifecycle router", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── updateStatus ───────────────────────────────────────────────────────────── + + describe("updateStatus", () => { + it("successfully transitions DRAFT → ACTIVE", async () => { + const updated = { ...baseProject, status: ProjectStatus.ACTIVE }; + const db = { + project: { + findUnique: vi + .fn() + .mockResolvedValue({ id: baseProject.id, status: ProjectStatus.DRAFT }), + update: vi.fn().mockResolvedValue(updated), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.updateStatus({ + id: baseProject.id, + status: ProjectStatus.ACTIVE, + }); + + expect(db.project.update).toHaveBeenCalledWith({ + where: { id: baseProject.id }, + data: { status: ProjectStatus.ACTIVE }, + }); + expect(result.status).toBe(ProjectStatus.ACTIVE); + }); + + it("calls webhook and cache invalidation after successful status update", async () => { + const updated = { ...baseProject, status: ProjectStatus.ACTIVE }; + const db = { + project: { + findUnique: vi + .fn() + .mockResolvedValue({ id: baseProject.id, status: ProjectStatus.DRAFT }), + update: vi.fn().mockResolvedValue(updated), + }, + }; + + const caller = createManagerCaller(db); + await caller.updateStatus({ id: baseProject.id, status: ProjectStatus.ACTIVE }); + + expect(deps.invalidateDashboardCacheInBackground).toHaveBeenCalledOnce(); + expect(deps.dispatchProjectWebhookInBackground).toHaveBeenCalledOnce(); + expect(deps.dispatchProjectWebhookInBackground).toHaveBeenCalledWith( + db as never, + "project.status_changed", + expect.objectContaining({ id: updated.id, status: ProjectStatus.ACTIVE }), + ); + }); + + it("is a no-op when the target status equals the current status", async () => { + const unchanged = { ...baseProject, status: ProjectStatus.DRAFT }; + const db = { + project: { + findUnique: vi + .fn() + .mockResolvedValue({ id: baseProject.id, status: ProjectStatus.DRAFT }), + update: vi.fn().mockResolvedValue(unchanged), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.updateStatus({ id: baseProject.id, status: ProjectStatus.DRAFT }); + + // update is still called (same status written back), but no transition validation fires + expect(db.project.update).toHaveBeenCalledOnce(); + expect(result.status).toBe(ProjectStatus.DRAFT); + }); + + it("throws NOT_FOUND when the project does not exist", async () => { + const db = { + project: { + findUnique: vi.fn().mockResolvedValue(null), + update: vi.fn(), + }, + }; + + const caller = createManagerCaller(db); + await expect( + caller.updateStatus({ id: "proj_missing", status: ProjectStatus.ACTIVE }), + ).rejects.toMatchObject({ code: "NOT_FOUND", message: "Project not found" }); + + expect(db.project.update).not.toHaveBeenCalled(); + }); + + it("throws BAD_REQUEST for an invalid transition (DRAFT → COMPLETED)", async () => { + const db = { + project: { + findUnique: vi + .fn() + .mockResolvedValue({ id: baseProject.id, status: ProjectStatus.DRAFT }), + update: vi.fn(), + }, + }; + + const caller = createManagerCaller(db); + await expect( + caller.updateStatus({ id: baseProject.id, status: ProjectStatus.COMPLETED }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + + expect(db.project.update).not.toHaveBeenCalled(); + }); + + it("throws BAD_REQUEST for COMPLETED → ON_HOLD (not an allowed transition)", async () => { + const db = { + project: { + findUnique: vi + .fn() + .mockResolvedValue({ id: baseProject.id, status: ProjectStatus.COMPLETED }), + update: vi.fn(), + }, + }; + + const caller = createManagerCaller(db); + await expect( + caller.updateStatus({ id: baseProject.id, status: ProjectStatus.ON_HOLD }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + + expect(db.project.update).not.toHaveBeenCalled(); + }); + }); + + // ── batchUpdateStatus ───────────────────────────────────────────────────────── + + describe("batchUpdateStatus", () => { + it("updates multiple projects inside a transaction", async () => { + const txMock = makeTxMock(); + txMock.project.update + .mockResolvedValueOnce({ id: "proj_1", status: ProjectStatus.ON_HOLD }) + .mockResolvedValueOnce({ id: "proj_2", status: ProjectStatus.ON_HOLD }); + + const db = { + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + }; + + const caller = createManagerCaller(db); + const result = await caller.batchUpdateStatus({ + ids: ["proj_1", "proj_2"], + status: ProjectStatus.ON_HOLD, + }); + + expect(txMock.project.update).toHaveBeenCalledTimes(2); + expect(result).toEqual({ count: 2 }); + }); + + it("calls invalidateDashboardCacheInBackground after a successful batch update", async () => { + const txMock = makeTxMock(); + txMock.project.update.mockResolvedValue({ id: "proj_1", status: ProjectStatus.ACTIVE }); + + const db = { + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + }; + + const caller = createManagerCaller(db); + await caller.batchUpdateStatus({ ids: ["proj_1"], status: ProjectStatus.ACTIVE }); + + expect(deps.invalidateDashboardCacheInBackground).toHaveBeenCalledOnce(); + }); + + it("creates an audit log entry inside the transaction", async () => { + const txMock = makeTxMock(); + txMock.project.update.mockResolvedValue({ id: "proj_1", status: ProjectStatus.CANCELLED }); + + const db = { + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + }; + + const caller = createManagerCaller(db); + await caller.batchUpdateStatus({ ids: ["proj_1"], status: ProjectStatus.CANCELLED }); + + expect(txMock.auditLog.create).toHaveBeenCalledOnce(); + expect(txMock.auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + entityType: "Project", + action: "UPDATE", + }), + }), + ); + }); + }); + + // ── delete ──────────────────────────────────────────────────────────────────── + + describe("delete", () => { + it("deletes a project with cascade (assignments, demandRequirements, calculationRules)", async () => { + const txMock = makeTxMock(); + const db = { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "proj_1", + name: "Alpha Project", + shortCode: "ALF-001", + }), + }, + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + }; + + const caller = createAdminCaller(db); + await caller.delete({ id: "proj_1" }); + + expect(txMock.assignment.deleteMany).toHaveBeenCalledWith({ where: { projectId: "proj_1" } }); + expect(txMock.demandRequirement.deleteMany).toHaveBeenCalledWith({ + where: { projectId: "proj_1" }, + }); + expect(txMock.calculationRule.updateMany).toHaveBeenCalledWith({ + where: { projectId: "proj_1" }, + data: { projectId: null }, + }); + expect(txMock.project.delete).toHaveBeenCalledWith({ where: { id: "proj_1" } }); + }); + + it("throws NOT_FOUND when the project does not exist", async () => { + const db = { + project: { + findUnique: vi.fn().mockResolvedValue(null), + }, + $transaction: vi.fn(), + }; + + const caller = createAdminCaller(db); + await expect(caller.delete({ id: "proj_missing" })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Project not found", + }); + + expect(db.$transaction).not.toHaveBeenCalled(); + }); + + it("calls invalidateDashboardCacheInBackground after a successful delete", async () => { + const txMock = makeTxMock(); + const db = { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "proj_1", + name: "Alpha Project", + shortCode: "ALF-001", + }), + }, + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + }; + + const caller = createAdminCaller(db); + await caller.delete({ id: "proj_1" }); + + expect(deps.invalidateDashboardCacheInBackground).toHaveBeenCalledOnce(); + }); + + it("returns the id and name of the deleted project", async () => { + const txMock = makeTxMock(); + const db = { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "proj_1", + name: "Alpha Project", + shortCode: "ALF-001", + }), + }, + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + }; + + const caller = createAdminCaller(db); + const result = await caller.delete({ id: "proj_1" }); + + expect(result).toEqual({ id: "proj_1", name: "Alpha Project" }); + }); + }); + + // ── batchDelete ─────────────────────────────────────────────────────────────── + + describe("batchDelete", () => { + it("deletes multiple projects with cascade inside a transaction", async () => { + const txMock = makeTxMock(); + const projects = [ + { id: "proj_1", name: "Alpha", shortCode: "ALF-001" }, + { id: "proj_2", name: "Beta", shortCode: "BET-001" }, + ]; + const db = { + project: { + findMany: vi.fn().mockResolvedValue(projects), + }, + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + }; + + const caller = createAdminCaller(db); + const result = await caller.batchDelete({ ids: ["proj_1", "proj_2"] }); + + expect(txMock.assignment.deleteMany).toHaveBeenCalledWith({ + where: { projectId: { in: ["proj_1", "proj_2"] } }, + }); + expect(txMock.demandRequirement.deleteMany).toHaveBeenCalledWith({ + where: { projectId: { in: ["proj_1", "proj_2"] } }, + }); + expect(txMock.calculationRule.updateMany).toHaveBeenCalledWith({ + where: { projectId: { in: ["proj_1", "proj_2"] } }, + data: { projectId: null }, + }); + expect(txMock.project.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ["proj_1", "proj_2"] } }, + }); + expect(result).toEqual({ count: 2 }); + }); + + it("throws NOT_FOUND when none of the requested projects exist", async () => { + const db = { + project: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: vi.fn(), + }; + + const caller = createAdminCaller(db); + await expect(caller.batchDelete({ ids: ["proj_missing"] })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "No projects found", + }); + + expect(db.$transaction).not.toHaveBeenCalled(); + }); + + it("returns the count of deleted projects", async () => { + const txMock = makeTxMock(); + const projects = [ + { id: "proj_1", name: "Alpha", shortCode: "ALF-001" }, + { id: "proj_2", name: "Beta", shortCode: "BET-001" }, + { id: "proj_3", name: "Gamma", shortCode: "GAM-001" }, + ]; + const db = { + project: { + findMany: vi.fn().mockResolvedValue(projects), + }, + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + }; + + const caller = createAdminCaller(db); + const result = await caller.batchDelete({ ids: ["proj_1", "proj_2", "proj_3"] }); + + expect(result).toEqual({ count: 3 }); + }); + }); +});