diff --git a/packages/api/src/__tests__/timeline-entry-procedure-support.test.ts b/packages/api/src/__tests__/timeline-entry-procedure-support.test.ts new file mode 100644 index 0000000..a480ec7 --- /dev/null +++ b/packages/api/src/__tests__/timeline-entry-procedure-support.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../lib/anonymization.js", () => ({ + getAnonymizationDirectory: vi.fn(), +})); + +vi.mock("../router/timeline-read-shared.js", () => ({ + buildSelfServiceTimelineInput: vi.fn(), + buildTimelineEntriesDetailInput: vi.fn(), + createEmptyTimelineEntriesView: vi.fn(), + loadTimelineEntriesReadModel: vi.fn(), +})); + +vi.mock("../router/timeline-entry-response-support.js", () => ({ + buildTimelineEntriesDetailResponse: vi.fn(), + buildTimelineEntriesViewResponse: vi.fn(), +})); + +vi.mock("../router/timeline-holiday-read.js", () => ({ + formatHolidayOverlays: vi.fn(), + loadTimelineHolidayOverlaysForReadModel: vi.fn(), + summarizeHolidayOverlays: vi.fn(), +})); + +import { getAnonymizationDirectory } from "../lib/anonymization.js"; +import { + buildSelfServiceTimelineInput, + buildTimelineEntriesDetailInput, + createEmptyTimelineEntriesView, + loadTimelineEntriesReadModel, +} from "../router/timeline-read-shared.js"; +import { + buildTimelineEntriesDetailResponse, + buildTimelineEntriesViewResponse, +} from "../router/timeline-entry-response-support.js"; +import { + formatHolidayOverlays, + loadTimelineHolidayOverlaysForReadModel, + summarizeHolidayOverlays, +} from "../router/timeline-holiday-read.js"; +import { + readMyTimelineEntriesView, + readTimelineEntries, + readTimelineEntriesDetail, + readTimelineEntriesView, +} from "../router/timeline-entry-procedure-support.js"; + +const getAnonymizationDirectoryMock = vi.mocked(getAnonymizationDirectory); +const buildSelfServiceTimelineInputMock = vi.mocked(buildSelfServiceTimelineInput); +const buildTimelineEntriesDetailInputMock = vi.mocked(buildTimelineEntriesDetailInput); +const createEmptyTimelineEntriesViewMock = vi.mocked(createEmptyTimelineEntriesView); +const loadTimelineEntriesReadModelMock = vi.mocked(loadTimelineEntriesReadModel); +const buildTimelineEntriesDetailResponseMock = vi.mocked(buildTimelineEntriesDetailResponse); +const buildTimelineEntriesViewResponseMock = vi.mocked(buildTimelineEntriesViewResponse); +const formatHolidayOverlaysMock = vi.mocked(formatHolidayOverlays); +const loadTimelineHolidayOverlaysForReadModelMock = vi.mocked(loadTimelineHolidayOverlaysForReadModel); +const summarizeHolidayOverlaysMock = vi.mocked(summarizeHolidayOverlays); + +describe("timeline entry procedure support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns anonymized allocations for entry list reads", async () => { + const readModel = { allocations: [{ id: "allocation_1" }], assignments: [] }; + const directory = { enabled: true }; + const response = { allocations: [{ id: "allocation_1", anonymized: true }], assignments: [] }; + + loadTimelineEntriesReadModelMock.mockResolvedValueOnce(readModel as never); + getAnonymizationDirectoryMock.mockResolvedValueOnce(directory as never); + buildTimelineEntriesViewResponseMock.mockReturnValueOnce(response as never); + + await expect( + readTimelineEntries({} as never, { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-07T00:00:00.000Z"), + }), + ).resolves.toEqual([{ id: "allocation_1", anonymized: true }]); + + expect(loadTimelineEntriesReadModelMock).toHaveBeenCalledWith({}, { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-07T00:00:00.000Z"), + }); + expect(getAnonymizationDirectoryMock).toHaveBeenCalledWith({}); + expect(buildTimelineEntriesViewResponseMock).toHaveBeenCalledWith(readModel, directory); + }); + + it("returns an empty entries view when self-service scope cannot be resolved", async () => { + buildSelfServiceTimelineInputMock.mockResolvedValueOnce(null); + createEmptyTimelineEntriesViewMock.mockReturnValueOnce({ + allocations: [], + assignments: [], + demands: [], + } as never); + + await expect( + readMyTimelineEntriesView( + { db: {}, dbUser: { id: "user_1" } } as never, + { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-07T00:00:00.000Z"), + }, + ), + ).resolves.toEqual({ + allocations: [], + assignments: [], + demands: [], + }); + + expect(createEmptyTimelineEntriesViewMock).toHaveBeenCalledTimes(1); + expect(loadTimelineEntriesReadModelMock).not.toHaveBeenCalled(); + }); + + it("builds entry detail responses with formatted holiday overlays", async () => { + const period = { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-07T00:00:00.000Z"), + }; + const filters = { countryCodes: ["DE"] }; + const timelineInput = { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-07T00:00:00.000Z"), + countryCodes: ["DE"], + }; + const readModel = { + allocations: [{ id: "allocation_1" }], + demands: [{ id: "demand_1" }], + assignments: [{ id: "assignment_1" }], + }; + + buildTimelineEntriesDetailInputMock.mockReturnValueOnce({ + period, + filters, + timelineInput, + } as never); + loadTimelineEntriesReadModelMock.mockResolvedValueOnce(readModel as never); + getAnonymizationDirectoryMock.mockResolvedValueOnce({ enabled: true } as never); + loadTimelineHolidayOverlaysForReadModelMock.mockResolvedValueOnce([{ id: "overlay_1" }] as never); + formatHolidayOverlaysMock.mockReturnValueOnce([{ id: "formatted_1" }] as never); + summarizeHolidayOverlaysMock.mockReturnValueOnce({ + overlayCount: 1, + holidayResourceCount: 1, + byScope: [{ scope: "COUNTRY", count: 1 }], + } as never); + buildTimelineEntriesDetailResponseMock.mockReturnValueOnce({ detail: true } as never); + + await expect( + readTimelineEntriesDetail({} as never, { + startDate: "2026-04-01", + endDate: "2026-04-07", + countryCodes: ["DE"], + }), + ).resolves.toEqual({ detail: true }); + + expect(loadTimelineHolidayOverlaysForReadModelMock).toHaveBeenCalledWith( + {}, + timelineInput, + readModel, + ); + expect(formatHolidayOverlaysMock).toHaveBeenCalledWith([{ id: "overlay_1" }]); + expect(summarizeHolidayOverlaysMock).toHaveBeenCalledWith([{ id: "formatted_1" }]); + expect(buildTimelineEntriesDetailResponseMock).toHaveBeenCalledWith({ + period, + filters, + readModel, + directory: { enabled: true }, + holidayOverlays: [{ id: "formatted_1" }], + holidaySummary: { + overlayCount: 1, + holidayResourceCount: 1, + byScope: [{ scope: "COUNTRY", count: 1 }], + }, + }); + }); + + it("builds full entries views from loaded read models", async () => { + const readModel = { allocations: [{ id: "allocation_1" }], assignments: [{ id: "assignment_1" }] }; + const directory = { enabled: true }; + const response = { + allocations: [{ id: "allocation_1", anonymized: true }], + assignments: [{ id: "assignment_1", anonymized: true }], + }; + + loadTimelineEntriesReadModelMock.mockResolvedValueOnce(readModel as never); + getAnonymizationDirectoryMock.mockResolvedValueOnce(directory as never); + buildTimelineEntriesViewResponseMock.mockReturnValueOnce(response as never); + + await expect( + readTimelineEntriesView({} as never, { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-07T00:00:00.000Z"), + }), + ).resolves.toEqual(response); + }); +}); diff --git a/packages/api/src/router/timeline-entry-procedure-support.ts b/packages/api/src/router/timeline-entry-procedure-support.ts new file mode 100644 index 0000000..48f1077 --- /dev/null +++ b/packages/api/src/router/timeline-entry-procedure-support.ts @@ -0,0 +1,88 @@ +import { getAnonymizationDirectory } from "../lib/anonymization.js"; +import { + buildSelfServiceTimelineInput, + buildTimelineEntriesDetailInput, + createEmptyTimelineEntriesView, + loadTimelineEntriesReadModel, +} from "./timeline-read-shared.js"; +import { + buildTimelineEntriesDetailResponse, + buildTimelineEntriesViewResponse, +} from "./timeline-entry-response-support.js"; +import { + formatHolidayOverlays, + loadTimelineHolidayOverlaysForReadModel, + summarizeHolidayOverlays, +} from "./timeline-holiday-read.js"; + +type TimelineEntryProcedureDb = + & Parameters[0] + & Parameters[0] + & Parameters[0]; +type TimelineEntrySelfServiceContext = Parameters[0]; +type TimelineEntryViewInput = Parameters[1]; +type TimelineEntryDetailInput = Parameters[0]; + +async function loadTimelineEntriesViewResponse( + db: TimelineEntryProcedureDb, + input: TimelineEntryViewInput, +) { + const [readModel, directory] = await Promise.all([ + loadTimelineEntriesReadModel(db, input), + getAnonymizationDirectory(db), + ]); + + return buildTimelineEntriesViewResponse(readModel, directory); +} + +export async function readTimelineEntries( + db: TimelineEntryProcedureDb, + input: TimelineEntryViewInput, +) { + return (await loadTimelineEntriesViewResponse(db, input)).allocations; +} + +export async function readTimelineEntriesView( + db: TimelineEntryProcedureDb, + input: TimelineEntryViewInput, +) { + return loadTimelineEntriesViewResponse(db, input); +} + +export async function readMyTimelineEntriesView( + ctx: TimelineEntrySelfServiceContext, + input: TimelineEntryViewInput, +) { + const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input); + if (!selfServiceInput) { + return createEmptyTimelineEntriesView(); + } + + return readTimelineEntriesView(ctx.db, selfServiceInput); +} + +export async function readTimelineEntriesDetail( + db: TimelineEntryProcedureDb, + input: TimelineEntryDetailInput, +) { + const { period, filters, timelineInput } = buildTimelineEntriesDetailInput(input); + const [readModel, directory] = await Promise.all([ + loadTimelineEntriesReadModel(db, timelineInput), + getAnonymizationDirectory(db), + ]); + const holidayOverlays = await loadTimelineHolidayOverlaysForReadModel( + db, + timelineInput, + readModel, + ); + const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); + + return buildTimelineEntriesDetailResponse({ + period, + filters, + readModel, + directory, + holidayOverlays: formattedHolidayOverlays, + holidaySummary: summarizeHolidayOverlays(formattedHolidayOverlays), + }); +} diff --git a/packages/api/src/router/timeline-entry-read.ts b/packages/api/src/router/timeline-entry-read.ts index fdffc17..6a13ccd 100644 --- a/packages/api/src/router/timeline-entry-read.ts +++ b/packages/api/src/router/timeline-entry-read.ts @@ -1,58 +1,27 @@ import { z } from "zod"; -import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { controllerProcedure, protectedProcedure } from "../trpc.js"; import { - buildSelfServiceTimelineInput, - buildTimelineEntriesDetailInput, - createEmptyTimelineEntriesView, - loadTimelineEntriesReadModel, TimelineWindowFiltersSchema, } from "./timeline-read-shared.js"; import { - buildTimelineEntriesDetailResponse, - buildTimelineEntriesViewResponse, -} from "./timeline-entry-response-support.js"; -import { - formatHolidayOverlays, - loadTimelineHolidayOverlaysForReadModel, - summarizeHolidayOverlays, -} from "./timeline-holiday-read.js"; + readMyTimelineEntriesView, + readTimelineEntries, + readTimelineEntriesDetail, + readTimelineEntriesView, +} from "./timeline-entry-procedure-support.js"; export const timelineEntryReadProcedures = { getEntries: controllerProcedure .input(TimelineWindowFiltersSchema) - .query(async ({ ctx, input }) => { - const readModel = await loadTimelineEntriesReadModel(ctx.db, input); - const directory = await getAnonymizationDirectory(ctx.db); - return buildTimelineEntriesViewResponse(readModel, directory).allocations; - }), + .query(({ ctx, input }) => readTimelineEntries(ctx.db, input)), getEntriesView: controllerProcedure .input(TimelineWindowFiltersSchema) - .query(async ({ ctx, input }) => { - const [readModel, directory] = await Promise.all([ - loadTimelineEntriesReadModel(ctx.db, input), - getAnonymizationDirectory(ctx.db), - ]); - - return buildTimelineEntriesViewResponse(readModel, directory); - }), + .query(({ ctx, input }) => readTimelineEntriesView(ctx.db, input)), getMyEntriesView: protectedProcedure .input(TimelineWindowFiltersSchema) - .query(async ({ ctx, input }) => { - const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input); - if (!selfServiceInput) { - return createEmptyTimelineEntriesView(); - } - - const [readModel, directory] = await Promise.all([ - loadTimelineEntriesReadModel(ctx.db, selfServiceInput), - getAnonymizationDirectory(ctx.db), - ]); - - return buildTimelineEntriesViewResponse(readModel, directory); - }), + .query(({ ctx, input }) => readMyTimelineEntriesView(ctx, input)), getEntriesDetail: controllerProcedure .input( @@ -68,27 +37,5 @@ export const timelineEntryReadProcedures = { countryCodes: z.array(z.string()).optional(), }), ) - .query(async ({ ctx, input }) => { - const { period, filters, timelineInput } = buildTimelineEntriesDetailInput(input); - - const [readModel, directory] = await Promise.all([ - loadTimelineEntriesReadModel(ctx.db, timelineInput), - getAnonymizationDirectory(ctx.db), - ]); - const holidayOverlays = await loadTimelineHolidayOverlaysForReadModel( - ctx.db, - timelineInput, - readModel, - ); - const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); - - return buildTimelineEntriesDetailResponse({ - period, - filters, - readModel, - directory, - holidayOverlays: formattedHolidayOverlays, - holidaySummary: summarizeHolidayOverlays(formattedHolidayOverlays), - }); - }), + .query(({ ctx, input }) => readTimelineEntriesDetail(ctx.db, input)), };