refactor(api): extract timeline entry procedure support
This commit is contained in:
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,58 +1,27 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getAnonymizationDirectory } from "../lib/anonymization.js";
|
|
||||||
import { controllerProcedure, protectedProcedure } from "../trpc.js";
|
import { controllerProcedure, protectedProcedure } from "../trpc.js";
|
||||||
import {
|
import {
|
||||||
buildSelfServiceTimelineInput,
|
|
||||||
buildTimelineEntriesDetailInput,
|
|
||||||
createEmptyTimelineEntriesView,
|
|
||||||
loadTimelineEntriesReadModel,
|
|
||||||
TimelineWindowFiltersSchema,
|
TimelineWindowFiltersSchema,
|
||||||
} from "./timeline-read-shared.js";
|
} from "./timeline-read-shared.js";
|
||||||
import {
|
import {
|
||||||
buildTimelineEntriesDetailResponse,
|
readMyTimelineEntriesView,
|
||||||
buildTimelineEntriesViewResponse,
|
readTimelineEntries,
|
||||||
} from "./timeline-entry-response-support.js";
|
readTimelineEntriesDetail,
|
||||||
import {
|
readTimelineEntriesView,
|
||||||
formatHolidayOverlays,
|
} from "./timeline-entry-procedure-support.js";
|
||||||
loadTimelineHolidayOverlaysForReadModel,
|
|
||||||
summarizeHolidayOverlays,
|
|
||||||
} from "./timeline-holiday-read.js";
|
|
||||||
|
|
||||||
export const timelineEntryReadProcedures = {
|
export const timelineEntryReadProcedures = {
|
||||||
getEntries: controllerProcedure
|
getEntries: controllerProcedure
|
||||||
.input(TimelineWindowFiltersSchema)
|
.input(TimelineWindowFiltersSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(({ ctx, input }) => readTimelineEntries(ctx.db, input)),
|
||||||
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
|
|
||||||
const directory = await getAnonymizationDirectory(ctx.db);
|
|
||||||
return buildTimelineEntriesViewResponse(readModel, directory).allocations;
|
|
||||||
}),
|
|
||||||
|
|
||||||
getEntriesView: controllerProcedure
|
getEntriesView: controllerProcedure
|
||||||
.input(TimelineWindowFiltersSchema)
|
.input(TimelineWindowFiltersSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(({ ctx, input }) => readTimelineEntriesView(ctx.db, input)),
|
||||||
const [readModel, directory] = await Promise.all([
|
|
||||||
loadTimelineEntriesReadModel(ctx.db, input),
|
|
||||||
getAnonymizationDirectory(ctx.db),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return buildTimelineEntriesViewResponse(readModel, directory);
|
|
||||||
}),
|
|
||||||
|
|
||||||
getMyEntriesView: protectedProcedure
|
getMyEntriesView: protectedProcedure
|
||||||
.input(TimelineWindowFiltersSchema)
|
.input(TimelineWindowFiltersSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(({ ctx, input }) => readMyTimelineEntriesView(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);
|
|
||||||
}),
|
|
||||||
|
|
||||||
getEntriesDetail: controllerProcedure
|
getEntriesDetail: controllerProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -68,27 +37,5 @@ export const timelineEntryReadProcedures = {
|
|||||||
countryCodes: z.array(z.string()).optional(),
|
countryCodes: z.array(z.string()).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(({ ctx, input }) => readTimelineEntriesDetail(ctx.db, 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),
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user