From a22dee6d25895a809004225641b920abefa4cd26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 20:25:11 +0200 Subject: [PATCH] refactor(api): extract country procedures --- .../country-procedure-support.test.ts | 156 ++++++++++ .../api/src/__tests__/country-router.test.ts | 152 ++++++++++ .../src/router/country-procedure-support.ts | 274 ++++++++++++++++++ packages/api/src/router/country.ts | 251 +++------------- 4 files changed, 618 insertions(+), 215 deletions(-) create mode 100644 packages/api/src/__tests__/country-procedure-support.test.ts create mode 100644 packages/api/src/__tests__/country-router.test.ts create mode 100644 packages/api/src/router/country-procedure-support.ts diff --git a/packages/api/src/__tests__/country-procedure-support.test.ts b/packages/api/src/__tests__/country-procedure-support.test.ts new file mode 100644 index 0000000..b79dd54 --- /dev/null +++ b/packages/api/src/__tests__/country-procedure-support.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { createAuditEntry } = vi.hoisted(() => ({ + createAuditEntry: vi.fn(), +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +import { + createCountry, + deleteMetroCity, + updateCountry, +} from "../router/country-procedure-support.js"; + +function createContext(db: Record) { + return { + db: db as never, + dbUser: { id: "user_admin" } as never, + }; +} + +describe("country procedure support", () => { + beforeEach(() => { + createAuditEntry.mockReset(); + }); + + it("creates a country after checking code uniqueness", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const create = vi.fn().mockResolvedValue({ + id: "country_de", + code: "DE", + name: "Germany", + metroCities: [], + }); + + const result = await createCountry( + createContext({ + country: { findUnique, create }, + }), + { + code: "DE", + name: "Germany", + dailyWorkingHours: 8, + }, + ); + + expect(findUnique).toHaveBeenCalledWith({ + where: { code: "DE" }, + }); + expect(create).toHaveBeenCalledWith({ + data: { + code: "DE", + name: "Germany", + dailyWorkingHours: 8, + }, + include: { metroCities: true }, + }); + expect(result.id).toBe("country_de"); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "Country", + action: "CREATE", + entityId: "country_de", + userId: "user_admin", + }), + ); + }); + + it("rechecks code uniqueness only when the code changes", async () => { + const findUnique = vi + .fn() + .mockResolvedValueOnce({ + id: "country_de", + code: "DE", + name: "Germany", + }) + .mockResolvedValueOnce(null); + const update = vi.fn().mockResolvedValue({ + id: "country_de", + code: "DEU", + name: "Germany", + metroCities: [], + }); + + const result = await updateCountry( + createContext({ + country: { findUnique, update }, + }), + { + id: "country_de", + data: { + code: "DEU", + }, + }, + ); + + expect(findUnique).toHaveBeenNthCalledWith(1, { + where: { id: "country_de" }, + }); + expect(findUnique).toHaveBeenNthCalledWith(2, { + where: { code: "DEU" }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "country_de" }, + data: { code: "DEU" }, + include: { metroCities: true }, + }); + expect(result.code).toBe("DEU"); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "Country", + action: "UPDATE", + before: expect.objectContaining({ code: "DE" }), + after: expect.objectContaining({ code: "DEU" }), + }), + ); + }); + + it("deletes a metro city only after the resource-count guard passes", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "city_berlin", + name: "Berlin", + _count: { resources: 0 }, + }); + const remove = vi.fn().mockResolvedValue({ id: "city_berlin" }); + + const result = await deleteMetroCity( + createContext({ + metroCity: { findUnique, delete: remove }, + }), + { id: "city_berlin" }, + ); + + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "city_berlin" }, + include: { _count: { select: { resources: true } } }, + }); + expect(remove).toHaveBeenCalledWith({ + where: { id: "city_berlin" }, + }); + expect(result).toEqual({ + success: true, + id: "city_berlin", + name: "Berlin", + }); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "MetroCity", + action: "DELETE", + entityId: "city_berlin", + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/country-router.test.ts b/packages/api/src/__tests__/country-router.test.ts new file mode 100644 index 0000000..b91094a --- /dev/null +++ b/packages/api/src/__tests__/country-router.test.ts @@ -0,0 +1,152 @@ +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { createAuditEntry } = vi.hoisted(() => ({ + createAuditEntry: vi.fn(), +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +import { countryRouter } from "../router/country.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(countryRouter); + +function createProtectedCaller( + db: Record, + options: { + role?: SystemRole; + granted?: PermissionKey[]; + } = {}, +) { + const { role = SystemRole.USER, granted = [] } = options; + + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: role === SystemRole.ADMIN ? "user_admin" : "user_1", + systemRole: role, + permissionOverrides: granted.length > 0 ? { granted } : null, + }, + }); +} + +describe("country router", () => { + beforeEach(() => { + createAuditEntry.mockReset(); + }); + + it("lists countries through the protected router", async () => { + const findMany = vi.fn().mockResolvedValue([ + { id: "country_de", code: "DE", name: "Germany", metroCities: [] }, + ]); + + const caller = createProtectedCaller({ + country: { findMany }, + }); + const result = await caller.list({ isActive: true }); + + expect(findMany).toHaveBeenCalledWith({ + where: { isActive: true }, + include: { metroCities: { orderBy: { name: "asc" } } }, + orderBy: { name: "asc" }, + }); + expect(result).toHaveLength(1); + }); + + it("allows detailed country reads with resource-overview permission", async () => { + const findFirst = vi.fn().mockResolvedValue({ + id: "country_de", + code: "DE", + name: "Germany", + isActive: true, + dailyWorkingHours: 8, + scheduleRules: null, + metroCities: [{ id: "city_berlin", name: "Berlin", countryId: "country_de" }], + _count: { resources: 4 }, + }); + + const caller = createProtectedCaller({ + country: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst, + }, + }, { + granted: [PermissionKey.VIEW_ALL_RESOURCES], + }); + const result = await caller.getByIdentifier({ identifier: "DE" }); + + expect(findFirst).toHaveBeenCalledWith({ + where: { code: { equals: "DE", mode: "insensitive" } }, + include: { + metroCities: { orderBy: { name: "asc" } }, + _count: { select: { resources: true } }, + }, + }); + expect(result._count.resources).toBe(4); + }); + + it("creates and updates countries through the admin router", async () => { + const findUnique = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "country_de", + code: "DE", + name: "Germany", + }); + const create = vi.fn().mockResolvedValue({ + id: "country_de", + code: "DE", + name: "Germany", + metroCities: [], + }); + const update = vi.fn().mockResolvedValue({ + id: "country_de", + code: "DE", + name: "Deutschland", + metroCities: [], + }); + + const caller = createProtectedCaller({ + country: { findUnique, create, update }, + }, { + role: SystemRole.ADMIN, + }); + + const created = await caller.create({ + code: "DE", + name: "Germany", + dailyWorkingHours: 8, + }); + const updated = await caller.update({ + id: "country_de", + data: { + name: "Deutschland", + }, + }); + + expect(create).toHaveBeenCalledWith({ + data: { + code: "DE", + name: "Germany", + dailyWorkingHours: 8, + }, + include: { metroCities: true }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "country_de" }, + data: { name: "Deutschland" }, + include: { metroCities: true }, + }); + expect(created.id).toBe("country_de"); + expect(updated.name).toBe("Deutschland"); + expect(createAuditEntry).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/api/src/router/country-procedure-support.ts b/packages/api/src/router/country-procedure-support.ts new file mode 100644 index 0000000..76ded99 --- /dev/null +++ b/packages/api/src/router/country-procedure-support.ts @@ -0,0 +1,274 @@ +import { + CreateCountrySchema, + CreateMetroCitySchema, + UpdateCountrySchema, + UpdateMetroCitySchema, +} from "@capakraken/shared"; +import type { Prisma } from "@capakraken/db"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { createAuditEntry } from "../lib/audit.js"; +import type { TRPCContext } from "../trpc.js"; +import { + assertCountryCodeAvailable, + assertMetroCityDeletable, + buildCountryCreateData, + buildCountryListWhere, + buildCountryUpdateData, + buildMetroCityCreateData, + buildMetroCityUpdateData, + findCountryByIdentifier, +} from "./country-support.js"; + +type CountryProcedureContext = Pick; + +type CountryIdentifierReadModel = { + id: string; + code: string; + name: string; + isActive: boolean; + dailyWorkingHours: number; +}; + +type CountryDetailReadModel = CountryIdentifierReadModel & { + scheduleRules?: Prisma.JsonValue | null; + metroCities: Array<{ id: string; name: string; countryId: string }>; + _count: { resources: number }; +}; + +function withAuditUser(userId: string | undefined) { + return userId ? { userId } : {}; +} + +export const countryListInputSchema = z.object({ + isActive: z.boolean().optional(), +}).optional(); + +export const countryIdentifierInputSchema = z.object({ + identifier: z.string().trim().min(1), +}); + +export const countryIdInputSchema = z.object({ + id: z.string(), +}); + +export const metroCityIdInputSchema = z.object({ + id: z.string(), +}); + +export const countryUpdateInputSchema = z.object({ + id: z.string(), + data: UpdateCountrySchema, +}); + +export const metroCityUpdateInputSchema = z.object({ + id: z.string(), + data: UpdateMetroCitySchema, +}); + +type CountryListInput = z.infer; +type CountryIdentifierInput = z.infer; +type CountryIdInput = z.infer; +type CreateCountryInput = z.infer; +type UpdateCountryInput = z.infer; +type CreateMetroCityInput = z.infer; +type UpdateMetroCityInput = z.infer; +type MetroCityIdInput = z.infer; + +export async function listCountries(ctx: CountryProcedureContext, input: CountryListInput) { + return ctx.db.country.findMany({ + where: buildCountryListWhere(input ?? {}), + include: { metroCities: { orderBy: { name: "asc" } } }, + orderBy: { name: "asc" }, + }); +} + +export async function resolveCountryByIdentifier( + ctx: CountryProcedureContext, + input: CountryIdentifierInput, +) { + return findCountryByIdentifier(ctx.db, input.identifier, { + select: { + id: true, + code: true, + name: true, + isActive: true, + dailyWorkingHours: true, + }, + }); +} + +export async function getCountryByIdentifier( + ctx: CountryProcedureContext, + input: CountryIdentifierInput, +) { + return findCountryByIdentifier(ctx.db, input.identifier, { + include: { + metroCities: { orderBy: { name: "asc" } }, + _count: { select: { resources: true } }, + }, + }); +} + +export async function getCountryById(ctx: CountryProcedureContext, input: CountryIdInput) { + return findUniqueOrThrow( + ctx.db.country.findUnique({ + where: { id: input.id }, + include: { + metroCities: { orderBy: { name: "asc" } }, + _count: { select: { resources: true } }, + }, + }), + "Country", + ); +} + +export async function getMetroCityById( + ctx: CountryProcedureContext, + input: MetroCityIdInput, +) { + return findUniqueOrThrow( + ctx.db.metroCity.findUnique({ + where: { id: input.id }, + select: { id: true, name: true, countryId: true }, + }), + "Metro city", + ); +} + +export async function createCountry(ctx: CountryProcedureContext, input: CreateCountryInput) { + await assertCountryCodeAvailable(ctx.db, input.code); + + const created = await ctx.db.country.create({ + data: buildCountryCreateData(input), + include: { metroCities: true }, + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Country", + entityId: created.id, + entityName: created.name, + action: "CREATE", + ...withAuditUser(ctx.dbUser?.id), + after: created as unknown as Record, + source: "ui", + }); + + return created; +} + +export async function updateCountry(ctx: CountryProcedureContext, input: UpdateCountryInput) { + const existing = await findUniqueOrThrow( + ctx.db.country.findUnique({ where: { id: input.id } }), + "Country", + ); + + if (input.data.code && input.data.code !== existing.code) { + await assertCountryCodeAvailable(ctx.db, input.data.code, existing.id); + } + + const before = existing as unknown as Record; + + const updated = await ctx.db.country.update({ + where: { id: input.id }, + data: buildCountryUpdateData(input.data), + include: { metroCities: true }, + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Country", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + before, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; +} + +export async function createMetroCity(ctx: CountryProcedureContext, input: CreateMetroCityInput) { + await findUniqueOrThrow( + ctx.db.country.findUnique({ where: { id: input.countryId } }), + "Country", + ); + + const created = await ctx.db.metroCity.create({ + data: buildMetroCityCreateData(input), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "MetroCity", + entityId: created.id, + entityName: created.name, + action: "CREATE", + ...withAuditUser(ctx.dbUser?.id), + after: created as unknown as Record, + source: "ui", + }); + + return created; +} + +export async function updateMetroCity( + ctx: CountryProcedureContext, + input: UpdateMetroCityInput, +) { + const existing = await findUniqueOrThrow( + ctx.db.metroCity.findUnique({ where: { id: input.id } }), + "Metro city", + ); + const before = existing as unknown as Record; + + const updated = await ctx.db.metroCity.update({ + where: { id: input.id }, + data: buildMetroCityUpdateData(input.data), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "MetroCity", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + before, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; +} + +export async function deleteMetroCity( + ctx: CountryProcedureContext, + input: MetroCityIdInput, +) { + const city = await findUniqueOrThrow( + ctx.db.metroCity.findUnique({ + where: { id: input.id }, + include: { _count: { select: { resources: true } } }, + }), + "Metro city", + ); + assertMetroCityDeletable(city); + await ctx.db.metroCity.delete({ where: { id: input.id } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "MetroCity", + entityId: city.id, + entityName: city.name, + action: "DELETE", + ...withAuditUser(ctx.dbUser?.id), + before: city as unknown as Record, + source: "ui", + }); + + return { success: true, id: city.id, name: city.name }; +} diff --git a/packages/api/src/router/country.ts b/packages/api/src/router/country.ts index 6ec6847..0ad5195 100644 --- a/packages/api/src/router/country.ts +++ b/packages/api/src/router/country.ts @@ -1,13 +1,3 @@ -import { - CreateCountrySchema, - CreateMetroCitySchema, - UpdateCountrySchema, - UpdateMetroCitySchema, -} from "@capakraken/shared"; -import type { Prisma } from "@capakraken/db"; -import { z } from "zod"; -import { findUniqueOrThrow } from "../db/helpers.js"; -import { createAuditEntry } from "../lib/audit.js"; import { adminProcedure, createTRPCRouter, @@ -15,234 +5,65 @@ import { resourceOverviewProcedure, } from "../trpc.js"; import { - assertCountryCodeAvailable, - assertMetroCityDeletable, - buildCountryCreateData, - buildCountryListWhere, - buildCountryUpdateData, - buildMetroCityCreateData, - buildMetroCityUpdateData, - findCountryByIdentifier, -} from "./country-support.js"; - -type CountryIdentifierReadModel = { - id: string; - code: string; - name: string; - isActive: boolean; - dailyWorkingHours: number; -}; - -type CountryDetailReadModel = CountryIdentifierReadModel & { - scheduleRules?: Prisma.JsonValue | null; - metroCities: Array<{ id: string; name: string; countryId: string }>; - _count: { resources: number }; -}; + countryIdInputSchema, + countryIdentifierInputSchema, + countryListInputSchema, + countryUpdateInputSchema, + createCountry, + createMetroCity, + deleteMetroCity, + getCountryById, + getCountryByIdentifier, + getMetroCityById, + listCountries, + metroCityIdInputSchema, + metroCityUpdateInputSchema, + resolveCountryByIdentifier, + updateCountry, + updateMetroCity, +} from "./country-procedure-support.js"; +import { CreateCountrySchema, CreateMetroCitySchema } from "@capakraken/shared"; export const countryRouter = createTRPCRouter({ list: protectedProcedure - .input(z.object({ isActive: z.boolean().optional() }).optional()) - .query(async ({ ctx, input }) => { - return ctx.db.country.findMany({ - where: buildCountryListWhere(input ?? {}), - include: { metroCities: { orderBy: { name: "asc" } } }, - orderBy: { name: "asc" }, - }); - }), + .input(countryListInputSchema) + .query(({ ctx, input }) => listCountries(ctx, input)), resolveByIdentifier: protectedProcedure - .input(z.object({ identifier: z.string().trim().min(1) })) - .query(async ({ ctx, input }) => { - return findCountryByIdentifier(ctx.db, input.identifier, { - select: { - id: true, - code: true, - name: true, - isActive: true, - dailyWorkingHours: true, - }, - }); - }), + .input(countryIdentifierInputSchema) + .query(({ ctx, input }) => resolveCountryByIdentifier(ctx, input)), getByIdentifier: resourceOverviewProcedure - .input(z.object({ identifier: z.string().trim().min(1) })) - .query(async ({ ctx, input }) => { - return findCountryByIdentifier(ctx.db, input.identifier, { - include: { - metroCities: { orderBy: { name: "asc" } }, - _count: { select: { resources: true } }, - }, - }); - }), + .input(countryIdentifierInputSchema) + .query(({ ctx, input }) => getCountryByIdentifier(ctx, input)), getById: resourceOverviewProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const country = await findUniqueOrThrow( - ctx.db.country.findUnique({ - where: { id: input.id }, - include: { - metroCities: { orderBy: { name: "asc" } }, - _count: { select: { resources: true } }, - }, - }), - "Country", - ); - return country; - }), + .input(countryIdInputSchema) + .query(({ ctx, input }) => getCountryById(ctx, input)), getCityById: protectedProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const city = await findUniqueOrThrow( - ctx.db.metroCity.findUnique({ - where: { id: input.id }, - select: { id: true, name: true, countryId: true }, - }), - "Metro city", - ); - return city; - }), + .input(metroCityIdInputSchema) + .query(({ ctx, input }) => getMetroCityById(ctx, input)), create: adminProcedure .input(CreateCountrySchema) - .mutation(async ({ ctx, input }) => { - await assertCountryCodeAvailable(ctx.db, input.code); - const created = await ctx.db.country.create({ - data: buildCountryCreateData(input), - include: { metroCities: true }, - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "Country", - entityId: created.id, - entityName: created.name, - action: "CREATE", - userId: ctx.dbUser?.id, - after: created as unknown as Record, - source: "ui", - }); - - return created; - }), + .mutation(({ ctx, input }) => createCountry(ctx, input)), update: adminProcedure - .input(z.object({ id: z.string(), data: UpdateCountrySchema })) - .mutation(async ({ ctx, input }) => { - const existing = await findUniqueOrThrow( - ctx.db.country.findUnique({ where: { id: input.id } }), - "Country", - ); - - if (input.data.code && input.data.code !== existing.code) { - await assertCountryCodeAvailable(ctx.db, input.data.code, existing.id); - } - - const before = existing as unknown as Record; - - const updated = await ctx.db.country.update({ - where: { id: input.id }, - data: buildCountryUpdateData(input.data), - include: { metroCities: true }, - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "Country", - entityId: updated.id, - entityName: updated.name, - action: "UPDATE", - userId: ctx.dbUser?.id, - before, - after: updated as unknown as Record, - source: "ui", - }); - - return updated; - }), + .input(countryUpdateInputSchema) + .mutation(({ ctx, input }) => updateCountry(ctx, input)), // ─── Metro City ───────────────────────────────────────────── createCity: adminProcedure .input(CreateMetroCitySchema) - .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( - ctx.db.country.findUnique({ where: { id: input.countryId } }), - "Country", - ); - - const created = await ctx.db.metroCity.create({ - data: buildMetroCityCreateData(input), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "MetroCity", - entityId: created.id, - entityName: created.name, - action: "CREATE", - userId: ctx.dbUser?.id, - after: created as unknown as Record, - source: "ui", - }); - - return created; - }), + .mutation(({ ctx, input }) => createMetroCity(ctx, input)), updateCity: adminProcedure - .input(z.object({ id: z.string(), data: UpdateMetroCitySchema })) - .mutation(async ({ ctx, input }) => { - const existing = await findUniqueOrThrow( - ctx.db.metroCity.findUnique({ where: { id: input.id } }), - "Metro city", - ); - const before = existing as unknown as Record; - - const updated = await ctx.db.metroCity.update({ - where: { id: input.id }, - data: buildMetroCityUpdateData(input.data), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "MetroCity", - entityId: updated.id, - entityName: updated.name, - action: "UPDATE", - userId: ctx.dbUser?.id, - before, - after: updated as unknown as Record, - source: "ui", - }); - - return updated; - }), + .input(metroCityUpdateInputSchema) + .mutation(({ ctx, input }) => updateMetroCity(ctx, input)), deleteCity: adminProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const city = await findUniqueOrThrow( - ctx.db.metroCity.findUnique({ - where: { id: input.id }, - include: { _count: { select: { resources: true } } }, - }), - "Metro city", - ); - assertMetroCityDeletable(city); - await ctx.db.metroCity.delete({ where: { id: input.id } }); - - void createAuditEntry({ - db: ctx.db, - entityType: "MetroCity", - entityId: city.id, - entityName: city.name, - action: "DELETE", - userId: ctx.dbUser?.id, - before: city as unknown as Record, - source: "ui", - }); - - return { success: true, id: city.id, name: city.name }; - }), + .input(metroCityIdInputSchema) + .mutation(({ ctx, input }) => deleteMetroCity(ctx, input)), });