refactor(api): extract timeline entry procedure support

This commit is contained in:
2026-03-31 18:18:52 +02:00
parent 72b13dfaba
commit 4758c96543
3 changed files with 292 additions and 62 deletions
@@ -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);
});
});
@@ -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<typeof getAnonymizationDirectory>[0]
& Parameters<typeof loadTimelineEntriesReadModel>[0]
& Parameters<typeof loadTimelineHolidayOverlaysForReadModel>[0];
type TimelineEntrySelfServiceContext = Parameters<typeof buildSelfServiceTimelineInput>[0];
type TimelineEntryViewInput = Parameters<typeof loadTimelineEntriesReadModel>[1];
type TimelineEntryDetailInput = Parameters<typeof buildTimelineEntriesDetailInput>[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),
});
}
+9 -62
View File
@@ -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)),
};