205 lines
5.2 KiB
TypeScript
205 lines
5.2 KiB
TypeScript
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<string, FakeEntity>()
|
|
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,
|
|
},
|
|
])
|
|
})
|
|
})
|