Files
Nexus/packages/api/src/__tests__/project-lifecycle-router.test.ts
T
Hartmut a0de69a520 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>
2026-04-10 16:37:02 +02:00

430 lines
15 KiB
TypeScript

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 });
});
});
});