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 { 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user