test(api): add 68 router tests for comment, project-lifecycle, dispo, holiday-calendar

Covers comment CRUD/resolve/delete, project status transitions and cascade
deletes, dispo import batch read/cancel/commit/resolve, and holiday calendar
catalog read with identifier fallback lookup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 16:37:02 +02:00
parent 2484eb9b9d
commit a0de69a520
4 changed files with 1561 additions and 136 deletions
@@ -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<typeof import("../lib/comment-entity-registry.js")>();
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<typeof import("../router/comment-support.js")>();
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<string, unknown> = {}) {
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<string, unknown> = {},
ctxOverrides: Record<string, unknown> = {},
) {
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<typeof vi.fn>).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<typeof vi.fn>;
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();
});
});
});
+423 -136
View File
@@ -1,172 +1,459 @@
import { ImportBatchStatus } from "@capakraken/db"; import { DispoStagedRecordType, ImportBatchStatus, StagedRecordStatus } from "@capakraken/db";
import { SystemRole } from "@capakraken/shared"; import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const { vi.mock("../lib/audit.js", () => ({
assessDispoImportReadiness,
stageDispoImportBatch,
} = vi.hoisted(() => ({
assessDispoImportReadiness: vi.fn(),
stageDispoImportBatch: vi.fn(),
}));
const { createAuditEntry } = vi.hoisted(() => ({
createAuditEntry: vi.fn(), createAuditEntry: vi.fn(),
})); }));
vi.mock("@capakraken/application", () => ({ vi.mock("@capakraken/application", () => ({
assessDispoImportReadiness, commitDispoImportBatch: vi.fn().mockResolvedValue({ resources: 5, projects: 3 }),
stageDispoImportBatch,
})); }));
vi.mock("../lib/audit.js", () => ({ import { createAuditEntry } from "../lib/audit.js";
createAuditEntry, 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"; // ─── dispo-management-support pure functions ──────────────────────────────────
import { createCallerFactory } from "../trpc.js";
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( expect(() =>
db: Record<string, unknown>, assertImportBatchCancelable({ id: "batch_1", status: ImportBatchStatus.REVIEW_READY }),
options: { role?: SystemRole } = {}, ).not.toThrow();
) {
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,
},
}); });
}
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(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("requires admin access for dispo import procedures", async () => { it("cancels a batch in a non-terminal status and creates an audit entry", async () => {
const findMany = vi.fn(); const batch = { id: "batch_1", status: ImportBatchStatus.STAGED };
const caller = createDispoCaller( const cancelled = { id: "batch_1", status: ImportBatchStatus.CANCELLED };
{ importBatch: { findMany } }, const findUnique = vi.fn().mockResolvedValue(batch);
{ role: SystemRole.USER }, const update = vi.fn().mockResolvedValue(cancelled);
); const db = { importBatch: { findUnique, update } };
await expect(caller.listImportBatches({ limit: 10 })).rejects.toMatchObject({ const result = await cancelImportBatch(db as never, { id: "batch_1", userId: "user_1" });
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",
});
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({ expect(findUnique).toHaveBeenCalledWith({
where: { id: "batch_1" }, where: { id: "batch_1" },
select: { id: true, status: true }, select: { id: true, status: true },
}); });
expect(update).toHaveBeenCalledWith({ expect(update).toHaveBeenCalledWith({
where: { id: "batch_1" }, where: { id: "batch_1" },
data: { data: { status: ImportBatchStatus.CANCELLED },
status: ImportBatchStatus.CANCELLED,
},
}); });
expect(staged).toEqual({ id: "batch_1", status: ImportBatchStatus.STAGED }); expect(result).toEqual(cancelled);
expect(validated).toEqual({ ready: true, issues: [] }); // Audit is fire-and-forget; verify it was initiated
expect(cancelled).toEqual({ id: "batch_1", status: ImportBatchStatus.CANCELLED });
expect(createAuditEntry).toHaveBeenCalledWith( expect(createAuditEntry).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
entityType: "ImportBatch", entityType: "ImportBatch",
entityId: "batch_1", entityId: "batch_1",
action: "UPDATE",
summary: "Cancelled import batch", 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", 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 },
}),
);
});
});
@@ -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<string, unknown>) {
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<string, unknown>) {
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();
});
});
@@ -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<string, unknown>) {
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<string, unknown>) {
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<unknown>) => 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<unknown>) => 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<unknown>) => 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<unknown>) => 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<unknown>) => 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<unknown>) => 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<unknown>) => 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<unknown>) => cb(txMock)),
};
const caller = createAdminCaller(db);
const result = await caller.batchDelete({ ids: ["proj_1", "proj_2", "proj_3"] });
expect(result).toEqual({ count: 3 });
});
});
});