feat(api): add timeline allocation fragment support

This commit is contained in:
2026-03-31 23:46:23 +02:00
parent f2d511ebc8
commit 9553aa0544
2 changed files with 360 additions and 0 deletions
@@ -0,0 +1,175 @@
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
loadAllocationEntryMock,
updateAssignmentMock,
createAssignmentMock,
deleteAllocationEntryMock,
} = vi.hoisted(() => ({
loadAllocationEntryMock: vi.fn(),
updateAssignmentMock: vi.fn(),
createAssignmentMock: vi.fn(),
deleteAllocationEntryMock: vi.fn(),
}));
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
loadAllocationEntry: loadAllocationEntryMock,
updateAssignment: updateAssignmentMock,
createAssignment: createAssignmentMock,
deleteAllocationEntry: deleteAllocationEntryMock,
};
});
import { carveTimelineAllocationRange } from "../router/timeline-allocation-fragment-support.js";
function createResolvedAssignment() {
return {
kind: "assignment" as const,
entry: {
id: "assignment_1",
startDate: new Date("2026-04-06T00:00:00.000Z"),
endDate: new Date("2026-04-17T00:00:00.000Z"),
hoursPerDay: 8,
metadata: {},
},
assignment: {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-04-06T00:00:00.000Z"),
endDate: new Date("2026-04-17T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
role: "Artist",
roleId: "role_1",
dailyCostCents: 80000,
status: "ACTIVE",
metadata: {},
},
projectId: "project_1",
resourceId: "resource_1",
};
}
describe("timeline allocation fragment support", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("splits an assignment into left and right fragments around the carved range", async () => {
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
updateAssignmentMock.mockResolvedValue({ id: "assignment_1" });
createAssignmentMock.mockResolvedValue({ id: "assignment_2" });
const db = {
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const result = await carveTimelineAllocationRange({
db: db as never,
allocationId: "assignment_1",
startDate: new Date("2026-04-09T00:00:00.000Z"),
endDate: new Date("2026-04-10T00:00:00.000Z"),
});
expect(result).toEqual({
action: "split",
allocationGroupId: expect.any(String),
updatedAllocationIds: ["assignment_1"],
createdAllocationIds: ["assignment_2"],
deletedAllocationIds: [],
projectId: "project_1",
resourceId: "resource_1",
});
expect(updateAssignmentMock).toHaveBeenCalledWith(
db,
"assignment_1",
expect.objectContaining({
startDate: new Date("2026-04-06T00:00:00.000Z"),
endDate: new Date("2026-04-08T00:00:00.000Z"),
}),
);
expect(createAssignmentMock).toHaveBeenCalledWith(
db,
expect.objectContaining({
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-04-11T00:00:00.000Z"),
endDate: new Date("2026-04-17T00:00:00.000Z"),
}),
);
});
it("shrinks the existing assignment when carving from the start edge", async () => {
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
updateAssignmentMock.mockResolvedValue({ id: "assignment_1" });
const db = {
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const result = await carveTimelineAllocationRange({
db: db as never,
allocationId: "assignment_1",
startDate: new Date("2026-04-06T00:00:00.000Z"),
endDate: new Date("2026-04-08T00:00:00.000Z"),
});
expect(result.action).toBe("updated");
expect(createAssignmentMock).not.toHaveBeenCalled();
expect(updateAssignmentMock).toHaveBeenCalledWith(
db,
"assignment_1",
expect.objectContaining({
startDate: new Date("2026-04-09T00:00:00.000Z"),
endDate: new Date("2026-04-17T00:00:00.000Z"),
}),
);
});
it("deletes the assignment when the carved range covers the full interval", async () => {
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
const db = {
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const result = await carveTimelineAllocationRange({
db: db as never,
allocationId: "assignment_1",
startDate: new Date("2026-04-06T00:00:00.000Z"),
endDate: new Date("2026-04-17T00:00:00.000Z"),
});
expect(result.action).toBe("deleted");
expect(deleteAllocationEntryMock).toHaveBeenCalledWith(
db,
expect.objectContaining({
assignment: expect.objectContaining({ id: "assignment_1" }),
}),
);
});
it("rejects carve ranges outside the allocation interval", async () => {
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
await expect(
carveTimelineAllocationRange({
db: { $transaction: vi.fn() } as never,
allocationId: "assignment_1",
startDate: new Date("2026-04-05T00:00:00.000Z"),
endDate: new Date("2026-04-06T00:00:00.000Z"),
}),
).rejects.toThrowError(
new TRPCError({
code: "BAD_REQUEST",
message: "The requested carve range must be fully inside the existing allocation.",
}),
);
});
});