import { describe, expect, it } from "vitest" import { z } from "zod" import { CrudNotFoundError, CrudValidationError } from "./errors" import { createCrudService } from "./service" type FakeEntity = { id: string title: string } type CreateFakeEntityInput = { title: string } type UpdateFakeEntityInput = { title?: string } function createMemoryRepository() { const state = new Map() let sequence = 0 return { list: async () => Array.from(state.values()), findById: async (id: string) => state.get(id) ?? null, create: async (input: CreateFakeEntityInput) => { sequence += 1 const created = { id: `${sequence}`, title: input.title, } state.set(created.id, created) return created }, update: async (id: string, input: UpdateFakeEntityInput) => { const current = state.get(id) if (!current) { throw new Error("unexpected missing entity in test repository") } const updated = { ...current, ...input, } state.set(id, updated) return updated }, delete: async (id: string) => { const current = state.get(id) if (!current) { throw new Error("unexpected missing entity in test repository") } state.delete(id) return current }, } } describe("createCrudService", () => { it("supports list and detail lookups through the repository contract", async () => { const service = createCrudService({ resource: "fake-entity", repository: createMemoryRepository(), schemas: { create: z.object({ title: z.string().min(3), }), update: z.object({ title: z.string().min(3).optional(), }), }, }) const createdA = await service.create({ title: "First" }) const createdB = await service.create({ title: "Second" }) expect(await service.getById(createdA.id)).toEqual(createdA) expect(await service.getById("missing")).toBeNull() const listed = await service.list() expect(listed).toHaveLength(2) expect(listed).toContainEqual(createdA) expect(listed).toContainEqual(createdB) }) it("validates create and update payloads", async () => { const service = createCrudService({ resource: "fake-entity", repository: createMemoryRepository(), schemas: { create: z.object({ title: z.string().min(3), }), update: z .object({ title: z.string().min(3).optional(), }) .refine((value) => Object.keys(value).length > 0, { message: "at least one field must be updated", }), }, }) await expect(service.create({ title: "ok" })).rejects.toBeInstanceOf(CrudValidationError) await expect(service.update("1", {})).rejects.toBeInstanceOf(CrudValidationError) }) it("throws not found for unknown update and delete", async () => { const service = createCrudService({ resource: "fake-entity", repository: createMemoryRepository(), schemas: { create: z.object({ title: z.string().min(3), }), update: z.object({ title: z.string().min(3).optional(), }), }, }) await expect(service.update("missing", { title: "Updated" })).rejects.toBeInstanceOf( CrudNotFoundError, ) await expect(service.delete("missing")).rejects.toBeInstanceOf(CrudNotFoundError) }) it("emits audit events for create, update and delete", async () => { const events: Array<{ action: string beforeTitle: string | null afterTitle: string | null actorRole: string | null requestId: string | null }> = [] const service = createCrudService({ resource: "fake-entity", repository: createMemoryRepository(), schemas: { create: z.object({ title: z.string().min(3), }), update: z.object({ title: z.string().min(3).optional(), }), }, auditHooks: [ (event) => { events.push({ action: event.action, beforeTitle: event.before?.title ?? null, afterTitle: event.after?.title ?? null, actorRole: event.actor?.role ?? null, requestId: typeof event.metadata?.requestId === "string" ? event.metadata.requestId : null, }) }, ], }) const created = await service.create( { title: "Created" }, { actor: { id: "u-1", role: "owner" }, metadata: { requestId: "req-1", }, }, ) await service.update(created.id, { title: "Updated" }) await service.delete(created.id) expect(events).toEqual([ { action: "create", beforeTitle: null, afterTitle: "Created", actorRole: "owner", requestId: "req-1", }, { action: "update", beforeTitle: "Created", afterTitle: "Updated", actorRole: null, requestId: null, }, { action: "delete", beforeTitle: "Updated", afterTitle: null, actorRole: null, requestId: null, }, ]) }) })