feat: custom content types backend

This commit is contained in:
Florian Sylvain committed 2026-10-19 17:01:37 +02:00
1 parent ed67b7e70d
commit 58d58f5a04
41 files changed
+4086 -2

No files matched your search

+355
View File
@@ -0,0 +1,355 @@
Feature: Owners and admins define custom content types, and users manage entries of those types through a generic /v1 API
As a site owner or admin
I want to define content types made of ordered scalar fields, and then create entries of those types
So that I can store structured content beyond articles, with the schema enforced by the server
Background:
Given internal/domain/contenttype defines the entity ContentType with ID uint32, Key, Name,
Fields []Field, CreatedAt and UpdatedAt, with snake_case json tags
And Field is a value object, ordered by its position in ContentType.Fields, with Key, Label,
Type, Required, Unique, Default, Min, Max and Options
And the field types are "text", "richtext", "number", "boolean", "date" and "select"
And a content type key matches the domain slug rule and has 2 to 40 characters
And a field key matches ^[a-z][a-z0-9_]{0,39}$, is unique within its type, and a type has 1 to 50 fields
And internal/domain/contenttype defines the sentinels ErrContentTypeNotFound, ErrKeyTaken,
ErrInvalidKey, ErrInvalidField, ErrHasEntries and ErrSchemaIncompatible
And internal/domain/contenttype defines the interface Repository with Create, GetByKey, List,
Update and Delete
And internal/domain/entry defines the entity Entry with ID uint32, ContentTypeID, Data
(a JSON object), CreatedBy, UpdatedBy, CreatedAt and UpdatedAt
And internal/domain/entry defines the sentinels ErrEntryNotFound, ErrInvalidEntry and
ErrUniqueViolation, and the interface Repository with Create, Get, ListPage, Update, Delete,
DeleteByContentType, CountByContentType and FindByFieldValue(contentTypeID, fieldKey, value, excludeID)
And schema validation of a field list and of an entry's data lives in the domain/use cases,
never in handlers, and handler request structs still use validate tags
And the use cases CreateContentType, ListContentTypes, GetContentType, UpdateContentType,
DeleteContentType, CreateEntry, GetEntry, ListEntries, UpdateEntry and DeleteEntry depend only
on the two domain repositories and an injected clock
And persistence stores content types in one table with the fields as a JSON text column, and
entries in one generic table with the data as a JSON text column, so no DDL runs at runtime
and the SQL is portable across SQLite, MySQL/MariaDB and PostgreSQL
And both GORM models are added to the AutoMigrate list and wired only in api/dependecyInjection.go
And the built-in articles, authors, sites, media and users are not part of this engine
And the routes are mounted under /v1/content-type in a router with jwtauth.Verifier, apiKeyAuth,
AuditContext, RequireSignedIn, jwtauth.Authenticator, RequireCurrentToken and
RequireVerifiedUser, and POST /v1/content-type, PUT /v1/content-type/{key} and
DELETE /v1/content-type/{key} additionally sit behind RequireOwnerOrAdmin
And a content type response has exactly the keys id, key, name, fields, created_at, updated_at
And a field response has exactly the keys key, label, type, required, unique, default, min, max,
options, with null for an unset default, min and max and [] for no options
And an entry response has exactly the keys id, content_type, data, created_by, updated_by,
created_at, updated_at, where content_type is the type's key
And errors are plain text from http.Error, and the new apperr strings are
content_type_not_found, content_type_key_taken, content_type_has_entries,
invalid_content_type_key, invalid_field, schema_incompatible, entry_not_found and invalid_entry
And users: owner id 1 "marta" (verified), admin id 2 "dev" (verified), unverified id 4 "quinn"
And the sample type "recipe" has the fields, in order:
| key | type | required | unique | default | min | max | options |
| title | text | true | true | | 3 | 80 | |
| servings | number | false | false | 4 | 1 | 50 | |
| vegetarian | boolean | false | false | false | | | |
| published_on | date | false | false | | | | |
| difficulty | select | true | false | | | | easy, medium, hard |
| notes | richtext | false | false | | | 500 | |
Scenario: Creating a content type
Given a valid jwt cookie for admin 2
When the client sends POST /v1/content-type with body
{"key": "recipe", "name": "Recipe", "fields": [<the sample fields>]}
Then the response status is 201
And the body has exactly the keys id, key, name, fields, created_at, updated_at
And key is "recipe" and the fields come back in the order sent
And each field has exactly the keys key, label, type, required, unique, default, min, max, options
Scenario Outline: Creating a content type with an invalid body is refused and stores nothing
Given a valid jwt cookie for admin 2
When the client sends POST /v1/content-type with <body>
Then the response status is <status>
And the response body is <message>
And no content type exists in the repository
Examples:
| body | status | message |
| body not-json | 400 | apperr.BodyErrorMessage |
| body {} | 400 | text containing 'Key' and 'required' |
| {"key": "Recipe!", "name": "R", "fields": [<one text field>]} | 400 | invalid_content_type_key |
| {"key": "r", "name": "R", "fields": [<one text field>]} | 400 | invalid_content_type_key |
| {"key": "recipe", "name": "", "fields": [<one text field>]} | 400 | text containing 'Name' and 'required' |
| {"key": "recipe", "name": "R", "fields": []} | 422 | invalid_field |
| {"key": "recipe", "name": "R", "fields": [<51 fields>]} | 422 | invalid_field |
| a field key "Title" or "1st" or "a-b" | 422 | invalid_field |
| two fields with the key "title" | 422 | invalid_field |
| a field type "integer" | 422 | invalid_field |
| a select field with no options, an empty option, or a duplicated option | 422 | invalid_field |
| options on a non-select field | 422 | invalid_field |
| min or max on a boolean or select field | 422 | invalid_field |
| min greater than max | 422 | invalid_field |
| a negative or fractional min or max on a text or richtext field | 422 | invalid_field |
| a non-date min or max on a date field | 422 | invalid_field |
| unique true on a richtext or boolean field | 422 | invalid_field |
| a default of the wrong type, outside min/max, or not in the options | 422 | invalid_field |
| a default on a unique field | 422 | invalid_field |
Scenario: A content type key can't be reused
Given the content type "recipe" exists
When admin 2 sends POST /v1/content-type with the key "recipe"
Then the response status is 409
And the response body is content_type_key_taken
And only one content type exists
Scenario Outline: Writing content types needs a verified owner or admin
Given <caller>
When the client sends POST /v1/content-type with a valid body
Then the response status is <status>
And the response body is <message>
And no content type exists in the repository
Examples:
| caller | status | message |
| no credentials | 401 | the existing Authenticator response |
| a jwt cookie for user 4 | 403 | apperr.VerificationRequiredErrorMessage |
| a stale token_version cookie | 401 | the existing Authenticator response |
Scenario: An API key acts as its creator on the content type routes
Given an active key created by admin 2
When the client sends POST /v1/content-type with that key and no cookie and a valid body
Then the response status is 201
Scenario: Listing and reading content types
Given the content types "recipe" and "event" exist
And a valid jwt cookie for any verified user
When the client sends GET /v1/content-type
Then the response status is 200
And the body is a JSON array of both content types, ordered by name
When the client sends GET /v1/content-type/recipe
Then the response status is 200 and the body is the "recipe" content type
Scenario Outline: Reading a content type that doesn't exist or with a bad key
Given a valid jwt cookie for admin 2
When the client sends <method> /v1/content-type/<key> <body>
Then the response status is <status>
And the response body is <message>
Examples:
| method | key | body | status | message |
| GET | nope | | 404 | content_type_not_found |
| PUT | nope | and a valid schema body | 404 | content_type_not_found |
| DELETE | nope | | 404 | content_type_not_found |
| GET | Bad_Key | | 404 | content_type_not_found |
Scenario: Updating a content type before it has entries
Given the content type "recipe" exists with no entries
When admin 2 sends PUT /v1/content-type/recipe with a new name, a removed field, a reordered
list and a changed field type
Then the response status is 200
And the body shows the new name, the new field order and the new field type
And the key is still "recipe"
Scenario: The key of a content type can't be changed
Given the content type "recipe" exists
When admin 2 sends PUT /v1/content-type/recipe with body key "dish"
Then the response status is 200
And the key is still "recipe"
Scenario Outline: Updating a content type that has entries
Given the content type "recipe" has an entry {"title": "Pad thai", "difficulty": "easy"}
When admin 2 sends PUT /v1/content-type/recipe with <change>
Then the response status is <status>
And the response body is <message>
And the stored schema is <outcome>
Examples:
| change | status | message | outcome |
| a field's type changed from text to number | 422 | invalid_field | unchanged |
| a new required field without a default | 409 | schema_incompatible | unchanged |
| a new required field with a default | 200 | | updated |
| a new optional field | 200 | | updated |
| the field "notes" removed | 200 | | updated |
| the option "easy" removed from difficulty | 409 | schema_incompatible | unchanged |
| the option "hard" removed from difficulty | 200 | | updated |
| title max lowered below the stored title's length | 409 | schema_incompatible | unchanged |
| label, order or name changed | 200 | | updated |
Scenario: Making a field unique is refused when stored values collide
Given the type "recipe" has two entries with the title "Pad thai" and the title is not unique
When admin 2 sends PUT /v1/content-type/recipe making title unique
Then the response status is 409 with schema_incompatible and the schema is unchanged
Scenario: Values of a removed field are no longer returned
Given an entry with notes "Soak overnight" and a schema update that removes "notes"
When a user sends GET on that entry
Then data has no "notes" key
When the entry is saved again through PUT
Then the stored document has no "notes" key
Scenario: Deleting a content type
Given the content type "event" exists with no entries
When admin 2 sends DELETE /v1/content-type/event
Then the response status is 204 and the body is empty
And GET /v1/content-type/event returns 404 with content_type_not_found
Scenario: A content type with entries can't be deleted
Given the content type "recipe" has one entry
When admin 2 sends DELETE /v1/content-type/recipe
Then the response status is 409
And the response body is content_type_has_entries
And the type and its entry still exist
Scenario: Creating an entry applies defaults
Given the content type "recipe" exists and a valid jwt cookie for admin 2
When the client sends POST /v1/content-type/recipe/entry with body
{"data": {"title": "Pad thai", "difficulty": "easy"}}
Then the response status is 201
And the body has exactly the keys id, content_type, data, created_by, updated_by, created_at, updated_at
And content_type is "recipe", created_by and updated_by are 2
And data is {"title": "Pad thai", "difficulty": "easy", "servings": 4, "vegetarian": false}
And data has no "published_on" and no "notes" key
Scenario: An explicit value overrides the default and an explicit null is kept
Given the content type "recipe" exists
When the client creates an entry with {"title": "Soup", "difficulty": "easy", "servings": 2, "published_on": null}
Then data servings is 2 and published_on is null and no default is applied to published_on
Scenario Outline: Creating an entry that breaks the schema is refused and stores nothing
Given the content type "recipe" exists and a valid jwt cookie
When the client sends POST /v1/content-type/recipe/entry with <body>
Then the response status is <status>
And the response body is <message>
And no entry exists
Examples:
| body | status | message |
| body not-json | 400 | apperr.BodyErrorMessage |
| {} | 400 | apperr.BodyErrorMessage |
| {"data": "x"} | 400 | apperr.BodyErrorMessage |
| {"data": {"difficulty": "easy"}} | 422 | invalid_entry: title: required |
| {"data": {"title": null, "difficulty": "easy"}} | 422 | invalid_entry: title: required |
| {"data": {"title": 5, "difficulty": "easy"}} | 422 | invalid_entry: title: type |
| {"data": {"title": "ab", "difficulty": "easy"}} | 422 | invalid_entry: title: min |
| {"data": {"title": "<81 runes>", "difficulty": "easy"}} | 422 | invalid_entry: title: max |
| {"data": {"title": "Soup", "difficulty": "extreme"}} | 422 | invalid_entry: difficulty: options |
| {"data": {"title": "Soup", "difficulty": "easy", "servings": 0}} | 422 | invalid_entry: servings: min |
| {"data": {"title": "Soup", "difficulty": "easy", "servings": 51}} | 422 | invalid_entry: servings: max |
| {"data": {"title": "Soup", "difficulty": "easy", "servings": "4"}} | 422 | invalid_entry: servings: type |
| {"data": {"title": "Soup", "difficulty": "easy", "vegetarian": "yes"}} | 422 | invalid_entry: vegetarian: type |
| {"data": {"title": "Soup", "difficulty": "easy", "published_on": "2026-02-30"}} | 422 | invalid_entry: published_on: type |
| {"data": {"title": "Soup", "difficulty": "easy", "published_on": "2026-02-01T10:00:00Z"}} | 422 | invalid_entry: published_on: type |
| {"data": {"title": "Soup", "difficulty": "easy", "notes": "<501 runes>"}} | 422 | invalid_entry: notes: max |
| {"data": {"title": "Soup", "difficulty": "easy", "colour": "red"}} | 422 | invalid_entry: colour: unknown_field |
Scenario: Boundary values are accepted
Given the content type "recipe" exists
When entries are created with a title of exactly 3 runes, exactly 80 runes, servings 1 and servings 50
Then each response status is 201
Scenario: Unique fields reject duplicates and ignore the entry itself
Given the content type "recipe" has an entry 7 with the title "Pad thai"
When the client sends POST /v1/content-type/recipe/entry with the title "Pad thai"
Then the response status is 409
And the response body is "invalid_entry: title: unique"
When the client sends PUT /v1/content-type/recipe/entry/7 with the same title "Pad thai" and a new servings
Then the response status is 200
Scenario: Unique values are scoped to their content type and ignore nulls
Given a content type "dish" with a unique text field "title" and an entry titled "Pad thai"
And the content type "recipe" has an entry titled "Pad thai"
Then both entries exist
And two entries of a type with an optional unique field both leaving it absent are accepted
Scenario: Getting an entry
Given the entry 7 of type "recipe"
When the client sends GET /v1/content-type/recipe/entry/7
Then the response status is 200 and the body is the entry
When the client sends GET /v1/content-type/dish/entry/7
Then the response status is 404 and the body is entry_not_found
Scenario Outline: Entry routes with a bad id or a missing resource
Given a valid jwt cookie
When the client sends <method> <path> <body>
Then the response status is <status>
And the response body is <message>
Examples:
| method | path | body | status | message |
| GET | /v1/content-type/recipe/entry/abc | | 400 | apperr.IdUint32ErrorMessage |
| GET | /v1/content-type/recipe/entry/-1 | | 400 | apperr.IdUint32ErrorMessage |
| GET | /v1/content-type/recipe/entry/4294967296 | | 400 | apperr.IdUint32ErrorMessage |
| GET | /v1/content-type/recipe/entry/999 | | 404 | entry_not_found |
| PUT | /v1/content-type/recipe/entry/999 | and a valid data body | 404 | entry_not_found |
| DELETE | /v1/content-type/recipe/entry/999 | | 404 | entry_not_found |
| GET | /v1/content-type/nope/entry/1 | | 404 | content_type_not_found |
| POST | /v1/content-type/nope/entry | and a valid data body | 404 | content_type_not_found |
| GET | /v1/content-type/nope/entry | | 404 | content_type_not_found |
Scenario: Replacing an entry validates the whole document and keeps provenance
Given the entry 7 of type "recipe" created by admin 2, and a valid jwt cookie for owner 1
When owner 1 sends PUT /v1/content-type/recipe/entry/7 with {"data": {"title": "Green curry", "difficulty": "medium"}}
Then the response status is 200
And data is exactly {"title": "Green curry", "difficulty": "medium"} with no default applied
And created_by is 2 and updated_by is 1 and updated_at is later than created_at
When owner 1 sends PUT with {"data": {"difficulty": "medium"}}
Then the response status is 422 with invalid_entry: title: required and the entry is unchanged
Scenario: Deleting an entry
Given the entry 7 of type "recipe"
When the client sends DELETE /v1/content-type/recipe/entry/7
Then the response status is 204 and the body is empty
And GET on entry 7 returns 404 with entry_not_found
Scenario Outline: Listing entries is paginated and sortable
Given 30 entries of type "recipe", and entries of type "dish" that must not appear
When the client sends GET /v1/content-type/recipe/entry<query>
Then the response status is <status>
And <result>
Examples:
| query | status | result |
| | 200 | items has 25 entries, total 30, page 1, size 25, ordered updated_at desc |
| ?page=2 | 200 | items has 5 entries, total 30, page 2, size 25 |
| ?size=5&page=2 | 200 | items has 5 entries, page 2, size 5 |
| ?sort=id:asc | 200 | items ordered by id ascending |
| ?sort=created_at:desc | 200 | items ordered by created_at descending |
| ?page=99 | 200 | items is [] and total is 30 |
| ?page=0 | 400 | apperr.BodyErrorMessage |
| ?size=7 | 400 | apperr.BodyErrorMessage |
| ?sort=data:asc | 400 | apperr.BodyErrorMessage |
| ?page=abc | 400 | apperr.BodyErrorMessage |
Scenario: An empty content type lists no entries
Given the content type "event" with no entries
When the client sends GET /v1/content-type/event/entry
Then the body is {"items": [], "total": 0, "page": 1, "size": 25} and items is a JSON array, not null
Scenario Outline: Entry routes accept any verified user and API keys, and refuse everyone else
Given the content type "recipe" exists
When <caller> sends POST /v1/content-type/recipe/entry with a valid body
Then the response status is <status>
Examples:
| caller | status |
| an admin cookie | 201 |
| an owner cookie | 201 |
| an active API key of admin 2 | 201 |
| no credentials | 401 |
| a cookie for unverified 4 | 403 |
| a stale token_version cookie | 401 |
Scenario: Built-in resources are not reachable through the generic engine
Given a valid jwt cookie for admin 2
When the client creates a content type with the key "article" and sends GET /v1/content-type/article/entry
Then the routes behave as for any custom type and GET /v1/article is unchanged
And no article, author, site, media or user is read or written by the engine
Scenario: Use case: validating an entry against a schema returns domain sentinels
Given a fake contenttype.Repository holding "recipe" and a fake entry.Repository
When CreateEntry is called with data breaking each rule in the table above
Then errors.Is(err, entry.ErrInvalidEntry) is true for each, and the repository received no Create
And a duplicate unique value returns an error satisfying errors.Is(err, entry.ErrUniqueViolation)
Scenario: Use case: schema updates re-validate stored entries
Given a fake entry.Repository holding entries and a fake contenttype.Repository
When UpdateContentType is called with a schema that an existing entry violates
Then it returns contenttype.ErrSchemaIncompatible and the repository Update is not called
And a type change returns contenttype.ErrInvalidField
And DeleteContentType with entries returns contenttype.ErrHasEntries and nothing is deleted
+100
View File
@@ -0,0 +1,100 @@
# QA procedure: custom content types (backend core)
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
## Setup shared by every case
1. Call `validator.InitValidator()` once per test.
2. Add in-memory fakes with unique type names across the package: `mockContentTypeRepo`
(implements `contenttype.Repository`, `failNext("Update")` style switch) and `mockEntryRepo` (implements `entry.Repository`, with
`CountByContentType`; `FindByFieldValue` compares decoded
JSON values in Go; ordering, paging and per-type scoping implemented like the real contract; an
`createCalls` counter). Reuse `mockUserRepo`, `stubHasher` and the API key fakes.
3. Build the handler stack from a `*api.UseCases` container holding fakes and mount
`api.InitBackendRoutes(container, settings)` on a test server (unused use cases stay nil). If a nil
use case breaks a constructor, fall back to a fresh `chi.NewRouter()` mirroring the new
content type router: `jwtauth.Verifier`, key middleware, `RequireSignedIn`, `jwtauth.Authenticator`,
`RequireCurrentToken`, `RequireVerifiedUser`, with `RequireOwnerOrAdmin` on POST/PUT/DELETE
`/content-type`. Token: `jwtauth.New("HS256", []byte("test-secret"), nil)`.
4. Inject a fake clock (`now func() time.Time`) into the use cases so `updated_at` > `created_at`.
5. Seed users: owner 1 "marta", admin 2 "dev" (verified), unverified 4 "quinn". Helpers: `cookieFor(userId,
tokenVersion)`, `bearer(raw)`; create the API key through `POST /v1/api-key`.
6. Helper `sampleRecipe()` builds the "recipe" request body from the feature's table.
## Content type create and validation
7. Admin 2 `POST /v1/content-type` with the recipe -> `201`; key set is exactly `id`, `key`, `name`,
`fields`, `created_at`, `updated_at`; each field has exactly `key`, `label`, `type`, `required`,
`unique`, `default`, `min`, `max`, `options`; order preserved; `default`/`min`/`max` are JSON `null`
and `options` is `[]` when unset.
8. Invalid-body table (feature table): each case asserts status, exact body (or contains for
validator text), and that `mockContentTypeRepo` is empty. Cover not-json, `{}`, bad key (`Recipe!`,
1 char, 41 chars), empty name, 0 fields, 51 fields, bad/duplicate/overlong field keys, unknown type,
every min/max/options/unique/default rule violation.
9. Duplicate key -> `409` `content_type_key_taken`, one type stored.
10. Access table: no credentials `401`, user 4 `403` `apperr.VerificationRequiredErrorMessage`, stale
token_version `401`, API key of admin 2 `201`. Failures leave the repository empty.
## Content type read, update, delete
11. List returns an array ordered by name; `GET /{key}` returns the type; unknown or malformed key -> `404`
`content_type_not_found`; same for PUT and DELETE.
12. Update with no entries: name, order, removal and type change accepted (`200`); body key in the PUT is
ignored and the key stays.
13. Update with entries (table): type change `422 invalid_field`; new required without default `409
schema_incompatible`; new required with default `200`; new optional `200`; remove field `200`; remove
an option in use `409`; remove an unused option `200`; lower max below a stored value `409`; label,
order, name `200`. On every `409`/`422`, assert the stored schema is byte-identical to before
(`failNext` not used, compare the fake's stored type).
14. Make a field unique while two stored entries collide -> `409 schema_incompatible`, unchanged.
15. Removed field values are not returned on GET, and are absent from the stored document after the next
PUT of that entry.
16. Delete without entries -> `204` empty body, then `404`. Delete with entries -> `409
content_type_has_entries`, type and entries intact.
17. Repository failure on Update/Delete -> `500` "Internal server error", state unchanged.
## Entries
18. Create with defaults: response key set exactly `id`, `content_type`, `data`, `created_by`,
`updated_by`, `created_at`, `updated_at`; `data` equals `{"title","difficulty","servings":4,
"vegetarian":false}`; no `published_on`/`notes` key; `created_by` = `updated_by` = 2.
19. Explicit value beats default; explicit `null` is kept and no default is applied to it.
20. Invalid-data table (feature table, include 400s: not-json, `{}`, non-object `data`): assert status,
exact body, and `createCalls == 0`. Include the date cases `2026-02-30` and a datetime string, a
string number `"4"`, and an unknown field `colour`.
21. Boundaries: title of exactly 3 and 80 runes (use a multibyte string to pin rune counting), servings 1
and 50, notes 500 runes -> `201`; one beyond each -> `422`.
22. Unique: second entry with the same title -> `409` body `invalid_entry: title: unique`; PUT on the
same entry with its own title -> `200`; same title in another type -> `201`; two entries both
omitting an optional unique field -> `201`.
23. GET: entry of another type's key -> `404` `entry_not_found`; unknown type -> `404`
`content_type_not_found`; ids `abc`, `-1`, `4294967296` -> `400` `apperr.IdUint32ErrorMessage`.
24. PUT replaces data wholesale: no defaults re-applied; `created_by` unchanged, `updated_by` = caller,
`updated_at` > `created_at`; invalid replacement -> `422` and the stored entry unchanged.
25. DELETE -> `204`, empty body, then `404`; unknown id `404 entry_not_found`.
26. List: seed 30 recipes and some dishes; default page (25 items, total 30, page 1, size 25,
`updated_at:desc`), `?page=2`, `?size=5&page=2`, `?sort=id:asc`, `?sort=created_at:desc`, `?page=99`
(`items: []`, total 30). Bad `page=0`, `size=7`, `sort=data:asc`, `page=abc` -> `400`
`apperr.BodyErrorMessage`. Empty type -> `items` is `[]` (assert JSON `[]`, not `null`).
27. Access table on entry routes: admin, owner, API key -> `201`; none `401`; user 4 `403`; stale `401`.
A non-admin verified caller can read and write entries but not content types (only if a
non-owner/non-admin role exists in the fake; otherwise skip and note it).
28. Isolation: creating a type keyed `article` does not affect `GET /v1/article`; no built-in repository
fake is called by any case in this file.
## Use case level (no HTTP)
29. `CreateEntry`/`UpdateEntry` against `mockContentTypeRepo` + `mockEntryRepo`: each rule breach ->
`errors.Is(err, entry.ErrInvalidEntry)` and no write; duplicate -> `entry.ErrUniqueViolation`.
30. `UpdateContentType`: incompatible stored entry -> `contenttype.ErrSchemaIncompatible` and no `Update`
call; type change -> `ErrInvalidField`; `DeleteContentType` with entries ->
`ErrHasEntries`; unknown key -> `ErrContentTypeNotFound`; taken key -> `ErrKeyTaken`.
31. Schema validator unit table: every row of the "invalid body" table asserted on the domain function
directly with `errors.Is(err, contenttype.ErrInvalidField)`.
## Manual verification (not exercised by the Go tests)
- Real SQLite, then MySQL/MariaDB and PostgreSQL via the existing driver tasks: start `task dev:api`,
confirm `AutoMigrate` creates the content type and entry tables with the JSON documents in text
columns and no table gets created or altered when a new content type is defined.
- Create a type, restart the server and confirm types and entries persist; create 1,000 entries and
confirm list paging and the unique check stay responsive (the unique check is a portable lookup, not a
DB index).
- Fire two concurrent POSTs with the same unique value and record the outcome (known limitation: the
check is not transactional).
- With curl and an API key, create a type and an entry from a non-browser client and confirm a browser
origin in `CORS_ALLOWED_ORIGINS` can use PUT and DELETE (preflight allows them).