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,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