test(crud): finalize MVP1 gate CRUD contract coverage
This commit is contained in:
6
TODO.md
6
TODO.md
@ -32,9 +32,9 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
- [x] [P1] First-start onboarding route for initial owner creation (`/welcome`)
|
- [x] [P1] First-start onboarding route for initial owner creation (`/welcome`)
|
||||||
- [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links
|
- [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links
|
||||||
- [x] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access
|
- [x] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access
|
||||||
- [~] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
|
- [x] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
|
||||||
- [~] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
|
- [x] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
|
||||||
- [~] [P1] Shared error and audit hooks for CRUD mutations
|
- [x] [P1] Shared error and audit hooks for CRUD mutations
|
||||||
|
|
||||||
### Admin App
|
### Admin App
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,8 @@ MVP0 now includes a shared CRUD foundation package: `@cms/crud`.
|
|||||||
Current baseline:
|
Current baseline:
|
||||||
|
|
||||||
- Shared service factory: `createCrudService`
|
- Shared service factory: `createCrudService`
|
||||||
|
- Repository contract: `list`, `findById`, `create`, `update`, `delete`
|
||||||
|
- Service surface for list/detail/editor flows: `list`, `getById`, `create`, `update`, `delete`
|
||||||
- Shared validation error type: `CrudValidationError`
|
- Shared validation error type: `CrudValidationError`
|
||||||
- Shared not-found error type: `CrudNotFoundError`
|
- Shared not-found error type: `CrudNotFoundError`
|
||||||
- Shared mutation audit hook contract: `CrudAuditHook`
|
- Shared mutation audit hook contract: `CrudAuditHook`
|
||||||
@ -24,6 +26,11 @@ Current baseline:
|
|||||||
- `registerPostCrudAuditHook`
|
- `registerPostCrudAuditHook`
|
||||||
|
|
||||||
Validation for create/update is enforced by `@cms/content` schemas.
|
Validation for create/update is enforced by `@cms/content` schemas.
|
||||||
|
Contract tests validate:
|
||||||
|
|
||||||
|
- repository list/detail behavior via CRUD service
|
||||||
|
- validation and not-found errors
|
||||||
|
- audit payload propagation (`actor`, `metadata`)
|
||||||
|
|
||||||
The admin dashboard currently includes a temporary posts CRUD sandbox to validate this flow through a real app UI.
|
The admin dashboard currently includes a temporary posts CRUD sandbox to validate this flow through a real app UI.
|
||||||
|
|
||||||
|
|||||||
@ -63,6 +63,32 @@ function createMemoryRepository() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("createCrudService", () => {
|
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 () => {
|
it("validates create and update payloads", async () => {
|
||||||
const service = createCrudService({
|
const service = createCrudService({
|
||||||
resource: "fake-entity",
|
resource: "fake-entity",
|
||||||
@ -106,8 +132,13 @@ describe("createCrudService", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("emits audit events for create, update and delete", async () => {
|
it("emits audit events for create, update and delete", async () => {
|
||||||
const events: Array<{ action: string; beforeTitle: string | null; afterTitle: string | null }> =
|
const events: Array<{
|
||||||
[]
|
action: string
|
||||||
|
beforeTitle: string | null
|
||||||
|
afterTitle: string | null
|
||||||
|
actorRole: string | null
|
||||||
|
requestId: string | null
|
||||||
|
}> = []
|
||||||
const service = createCrudService({
|
const service = createCrudService({
|
||||||
resource: "fake-entity",
|
resource: "fake-entity",
|
||||||
repository: createMemoryRepository(),
|
repository: createMemoryRepository(),
|
||||||
@ -125,6 +156,9 @@ describe("createCrudService", () => {
|
|||||||
action: event.action,
|
action: event.action,
|
||||||
beforeTitle: event.before?.title ?? null,
|
beforeTitle: event.before?.title ?? null,
|
||||||
afterTitle: event.after?.title ?? null,
|
afterTitle: event.after?.title ?? null,
|
||||||
|
actorRole: event.actor?.role ?? null,
|
||||||
|
requestId:
|
||||||
|
typeof event.metadata?.requestId === "string" ? event.metadata.requestId : null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -134,6 +168,9 @@ describe("createCrudService", () => {
|
|||||||
{ title: "Created" },
|
{ title: "Created" },
|
||||||
{
|
{
|
||||||
actor: { id: "u-1", role: "owner" },
|
actor: { id: "u-1", role: "owner" },
|
||||||
|
metadata: {
|
||||||
|
requestId: "req-1",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -145,16 +182,22 @@ describe("createCrudService", () => {
|
|||||||
action: "create",
|
action: "create",
|
||||||
beforeTitle: null,
|
beforeTitle: null,
|
||||||
afterTitle: "Created",
|
afterTitle: "Created",
|
||||||
|
actorRole: "owner",
|
||||||
|
requestId: "req-1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: "update",
|
action: "update",
|
||||||
beforeTitle: "Created",
|
beforeTitle: "Created",
|
||||||
afterTitle: "Updated",
|
afterTitle: "Updated",
|
||||||
|
actorRole: null,
|
||||||
|
requestId: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: "delete",
|
action: "delete",
|
||||||
beforeTitle: "Updated",
|
beforeTitle: "Updated",
|
||||||
afterTitle: null,
|
afterTitle: null,
|
||||||
|
actorRole: null,
|
||||||
|
requestId: null,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user