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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>,
|
||||
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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user