1d6d75ecf6
- applyProjectScenario: wrap assignment loop in db.$transaction to prevent partial updates - vacation approve/reject: fix TOCTOU race via updateMany with status-guard in WHERE + CONFLICT on count=0 - vacation cancel: wrap vacation.update + entitlement.updateMany in $transaction - batchApprove: collect mutations, wrap in $transaction, dispatch SSE/notifications after commit - Fix dead-code bug in createHappyPathDb where $transaction was assigned after return - Add atomicity and concurrency tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
202 lines
6.8 KiB
TypeScript
202 lines
6.8 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { applyProjectScenario } from "../router/scenario-apply.js";
|
|
|
|
vi.mock("../lib/audit.js", () => ({
|
|
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
function makeDb(overrides: {
|
|
projectFindUnique?: ReturnType<typeof vi.fn>;
|
|
assignmentUpdate?: ReturnType<typeof vi.fn>;
|
|
assignmentCreate?: ReturnType<typeof vi.fn>;
|
|
resourceFindUnique?: ReturnType<typeof vi.fn>;
|
|
transaction?: ReturnType<typeof vi.fn>;
|
|
}) {
|
|
const db = {
|
|
project: {
|
|
findUnique: overrides.projectFindUnique ?? vi.fn().mockResolvedValue({ id: "project_1", name: "Test Project" }),
|
|
},
|
|
assignment: {
|
|
update: overrides.assignmentUpdate ?? vi.fn().mockResolvedValue({}),
|
|
create: overrides.assignmentCreate ?? vi.fn().mockResolvedValue({ id: "new_assignment_1" }),
|
|
},
|
|
resource: {
|
|
findUnique: overrides.resourceFindUnique ?? vi.fn().mockResolvedValue({ lcrCents: 100 }),
|
|
},
|
|
};
|
|
return {
|
|
...db,
|
|
$transaction: overrides.transaction ?? vi.fn(async (cb: (tx: typeof db) => Promise<unknown>) => cb(db)),
|
|
} as never;
|
|
}
|
|
|
|
const baseChange = {
|
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
|
endDate: new Date("2026-04-30T00:00:00.000Z"),
|
|
hoursPerDay: 8,
|
|
};
|
|
|
|
describe("applyProjectScenario", () => {
|
|
let assignmentUpdate: ReturnType<typeof vi.fn>;
|
|
let assignmentCreate: ReturnType<typeof vi.fn>;
|
|
let resourceFindUnique: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
assignmentUpdate = vi.fn().mockResolvedValue({});
|
|
assignmentCreate = vi.fn().mockResolvedValue({ id: "new_assignment_1" });
|
|
resourceFindUnique = vi.fn().mockResolvedValue({ lcrCents: 100 });
|
|
});
|
|
|
|
it("throws NOT_FOUND when the project does not exist", async () => {
|
|
const db = makeDb({
|
|
projectFindUnique: vi.fn().mockResolvedValue(null),
|
|
});
|
|
|
|
await expect(
|
|
applyProjectScenario(db, { projectId: "missing_project", changes: [] }),
|
|
).rejects.toMatchObject({ code: "NOT_FOUND", message: "Project not found" });
|
|
});
|
|
|
|
it("cancels an assignment when remove:true is supplied", async () => {
|
|
const db = makeDb({ assignmentUpdate });
|
|
|
|
const result = await applyProjectScenario(db, {
|
|
projectId: "project_1",
|
|
changes: [{ ...baseChange, assignmentId: "assignment_1", remove: true }],
|
|
});
|
|
|
|
expect(assignmentUpdate).toHaveBeenCalledWith({
|
|
where: { id: "assignment_1" },
|
|
data: { status: "CANCELLED" },
|
|
});
|
|
// Cancels continue early without pushing into `created`, so appliedCount is 0
|
|
expect(result.appliedCount).toBe(0);
|
|
});
|
|
|
|
it("updates dates and hours when assignmentId is present without remove", async () => {
|
|
const db = makeDb({ assignmentUpdate });
|
|
|
|
const result = await applyProjectScenario(db, {
|
|
projectId: "project_1",
|
|
changes: [{ ...baseChange, assignmentId: "assignment_1" }],
|
|
});
|
|
|
|
expect(assignmentUpdate).toHaveBeenCalledWith({
|
|
where: { id: "assignment_1" },
|
|
data: {
|
|
startDate: baseChange.startDate,
|
|
endDate: baseChange.endDate,
|
|
hoursPerDay: baseChange.hoursPerDay,
|
|
},
|
|
});
|
|
expect(result.appliedCount).toBe(1);
|
|
});
|
|
|
|
it("skips a change that has neither assignmentId nor resourceId", async () => {
|
|
const db = makeDb({ assignmentCreate, resourceFindUnique });
|
|
|
|
const result = await applyProjectScenario(db, {
|
|
projectId: "project_1",
|
|
changes: [{ ...baseChange }],
|
|
});
|
|
|
|
expect(assignmentCreate).not.toHaveBeenCalled();
|
|
expect(result.appliedCount).toBe(0);
|
|
});
|
|
|
|
it("creates a new assignment when only resourceId is present and computes dailyCostCents", async () => {
|
|
resourceFindUnique = vi.fn().mockResolvedValue({ lcrCents: 200 });
|
|
const db = makeDb({ assignmentCreate, resourceFindUnique });
|
|
|
|
const result = await applyProjectScenario(db, {
|
|
projectId: "project_1",
|
|
changes: [{ ...baseChange, resourceId: "resource_1", hoursPerDay: 6 }],
|
|
});
|
|
|
|
expect(resourceFindUnique).toHaveBeenCalledWith({
|
|
where: { id: "resource_1" },
|
|
select: { lcrCents: true },
|
|
});
|
|
|
|
// dailyCostCents = Math.round(lcrCents * hoursPerDay) = Math.round(200 * 6) = 1200
|
|
expect(assignmentCreate).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
projectId: "project_1",
|
|
resourceId: "resource_1",
|
|
hoursPerDay: 6,
|
|
dailyCostCents: 1200,
|
|
status: "PROPOSED",
|
|
}),
|
|
});
|
|
expect(result.appliedCount).toBe(1);
|
|
});
|
|
|
|
it("counts correctly across multiple mixed changes", async () => {
|
|
assignmentCreate = vi.fn()
|
|
.mockResolvedValueOnce({ id: "new_1" })
|
|
.mockResolvedValueOnce({ id: "new_2" });
|
|
assignmentUpdate = vi.fn().mockResolvedValue({});
|
|
|
|
const db = makeDb({ assignmentCreate, assignmentUpdate, resourceFindUnique });
|
|
|
|
const result = await applyProjectScenario(db, {
|
|
projectId: "project_1",
|
|
changes: [
|
|
// create 1
|
|
{ ...baseChange, resourceId: "resource_1" },
|
|
// create 2
|
|
{ ...baseChange, resourceId: "resource_2" },
|
|
// cancel
|
|
{ ...baseChange, assignmentId: "assignment_1", remove: true },
|
|
],
|
|
});
|
|
|
|
expect(assignmentCreate).toHaveBeenCalledTimes(2);
|
|
// Cancels continue early without pushing into `created`.
|
|
// appliedCount = 2 creates + 0 cancel = 2.
|
|
expect(result.appliedCount).toBe(2);
|
|
});
|
|
|
|
it("wraps all mutations in a single transaction", async () => {
|
|
assignmentCreate = vi.fn().mockResolvedValue({ id: "new_1" });
|
|
assignmentUpdate = vi.fn().mockResolvedValue({});
|
|
|
|
// transaction mock that propagates the error so we can verify atomicity
|
|
const transaction = vi.fn(async (cb: (tx: unknown) => Promise<unknown>) => cb({
|
|
assignment: { update: assignmentUpdate, create: assignmentCreate },
|
|
resource: { findUnique: resourceFindUnique },
|
|
}));
|
|
|
|
const db = makeDb({ assignmentCreate, assignmentUpdate, resourceFindUnique, transaction });
|
|
|
|
await applyProjectScenario(db, {
|
|
projectId: "project_1",
|
|
changes: [{ ...baseChange, resourceId: "resource_1" }],
|
|
});
|
|
|
|
expect(transaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("propagates transaction error so partial changes are rolled back", async () => {
|
|
assignmentCreate = vi.fn()
|
|
.mockResolvedValueOnce({ id: "new_1" })
|
|
.mockRejectedValueOnce(new Error("DB constraint violation"));
|
|
|
|
const transaction = vi.fn(async (cb: (tx: unknown) => Promise<unknown>) =>
|
|
cb({ assignment: { update: assignmentUpdate, create: assignmentCreate }, resource: { findUnique: resourceFindUnique } }),
|
|
);
|
|
|
|
const db = makeDb({ assignmentCreate, assignmentUpdate, resourceFindUnique, transaction });
|
|
|
|
await expect(
|
|
applyProjectScenario(db, {
|
|
projectId: "project_1",
|
|
changes: [
|
|
{ ...baseChange, resourceId: "resource_1" },
|
|
{ ...baseChange, resourceId: "resource_2" },
|
|
],
|
|
}),
|
|
).rejects.toThrow("DB constraint violation");
|
|
});
|
|
});
|