feat: custom content types backend
This commit is contained in:
1 parent
ed67b7e70d
commit
58d58f5a04
41 files changed
+4086
-2
No files matched your search
@@ -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
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user