a0de69a520
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>
430 lines
15 KiB
TypeScript
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 });
|
|
});
|
|
});
|
|
});
|