feat: custom content type icons, built-in types, user lookup

This commit is contained in:
Florian Sylvain committed 2026-10-19 18:22:55 +02:00
1 parent e6e088c963
commit 73a75d5cb9
31 files changed
+1611 -36

No files matched your search

+2 -2
View File
@@ -35,7 +35,7 @@ Feature: Owners and admins define custom content types, and users manage entries
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 content type response has exactly the keys id, key, name, icon, 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,
@@ -58,7 +58,7 @@ Feature: Owners and admins define custom content types, and users manage entries
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 the body has exactly the keys id, key, name, icon, 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
+1 -1
View File
@@ -21,7 +21,7 @@ Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
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`,
7. Admin 2 `POST /v1/content-type` with the recipe -> `201`; key set is exactly `id`, `key`, `name`, `icon`,
`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.
@@ -0,0 +1,235 @@
Feature: Content types carry an optional icon, built-in types are readable, and verified users can resolve user display names
As an owner, admin or verified user working in the SPA
I want an icon per content type, a read-only view of the built-in types and the display names of entry authors
So that the sidebar, the Schema screen and the entry screens can be drawn without leaking account data
Background:
Given the existing content type contract of custom_content_types.feature is unchanged except
that a content type response now has exactly the keys id, key, name, icon, fields, created_at,
updated_at
And a content type icon is null or a string matching ^[a-z][a-z0-9-]{0,39}$ and it is never
checked against a list of known icons
And internal/domain/contenttype defines the sentinel ErrInvalidIcon and the apperr string is
invalid_icon
And the icon is stored in a nullable column added by AutoMigrate, so existing rows read as null,
and the fields column stays a JSON text column
And an empty icon "" is stored as null
And a PUT replaces the icon like it replaces the name and the fields, so an absent icon clears it
And internal/domain/builtintype defines the static catalogue of the built-in types article,
author, site, media and user, in this order, each with a name and a list of fields shaped like
a content type field (key, label, type, required, unique, default, min, max, options)
And the catalogue uses no repository and no database, and the use cases ListBuiltinTypes and
GetBuiltinType depend only on that package
And the routes GET /v1/builtin-type and GET /v1/builtin-type/{key} are mounted in a router with
jwtauth.Verifier, apiKeyAuth, AuditContext, RequireSignedIn, jwtauth.Authenticator,
RequireCurrentToken, RequireVerifiedUser and RequireOwnerOrAdmin
And a built-in type response has exactly the keys key, name, fields
And the new apperr string is builtin_type_not_found
And the route GET /v1/user/lookup is mounted on the existing user router behind the verified
user chain with no role requirement
And a lookup item has exactly the keys id and display_name
And users: owner id 1 "marta" with display name "Marta Alves", admin id 2 "dev" with no
display name, verified id 3 "sam" with display name "Sam Roy", unverified id 4 "quinn"
And only the roles owner and admin exist, so every verified user passes RequireOwnerOrAdmin
And errors are plain text from http.Error
Scenario: Creating a content type with an icon
Given a valid jwt cookie for admin 2
When the client sends POST /v1/content-type with the recipe body and "icon": "chef-hat"
Then the response status is 201
And the body has exactly the keys id, key, name, icon, fields, created_at, updated_at
And icon is "chef-hat"
And GET /v1/content-type/recipe and GET /v1/content-type both show icon "chef-hat"
Scenario Outline: A content type without an icon has a null icon
Given a valid jwt cookie for admin 2
When the client creates "recipe" with <icon>
Then the response status is 201 and icon is null in the response, in GET /v1/content-type/recipe and in the list
Examples:
| icon |
| no icon key |
| "icon": null |
| "icon": "" |
Scenario: A content type stored before this slice still works
Given the content type "recipe" was stored with no icon value
When a verified user sends GET /v1/content-type and GET /v1/content-type/recipe
Then the response status is 200 and icon is null and all other keys are unchanged
Scenario Outline: An invalid icon is refused and stores nothing
Given a valid jwt cookie for admin 2
When the client sends POST /v1/content-type with a valid body and "icon": <icon>
Then the response status is 400
And the response body is invalid_icon
And no content type exists in the repository
Examples:
| icon |
| "Chef-Hat" |
| "1star" |
| "-star" |
| "chef hat" |
| "chef_hat" |
| "<41 characters>" |
| 5 |
Scenario: Boundary icons are accepted
Given a valid jwt cookie for admin 2
When the client creates types with the icons "a" and a 40 character icon "a" followed by 39 "b"
Then each response status is 201
Scenario Outline: Updating the icon of a content type
Given the content type "recipe" exists with the icon "chef-hat" and has an entry
When admin 2 sends PUT /v1/content-type/recipe with the same fields and <icon>
Then the response status is <status>
And <outcome>
Examples:
| icon | status | outcome |
| "icon": "utensils" | 200 | icon is "utensils" |
| no icon key | 200 | icon is null |
| "icon": null | 200 | icon is null |
| "icon": "Bad Icon" | 400 | the body is invalid_icon and the icon is still "chef-hat" |
Scenario: The icon does not affect entries
Given the content type "recipe" has an entry and admin 2 changes only the icon
Then the response status is 200 and the entry data and its updated_at are unchanged
Scenario: Listing the built-in types
Given a valid jwt cookie for owner 1
When the client sends GET /v1/builtin-type
Then the response status is 200
And the body is a JSON array of 5 items with the keys article, author, site, media, user in this order
And each item has exactly the keys key, name, fields
And the names are "Articles", "Authors", "Sites", "Media" and "Users"
And every field has exactly the keys key, label, type, required, unique, default, min, max, options
And every field type is one of text, richtext, number, boolean, date, select
Scenario Outline: The built-in field lists match the real API
Given a valid jwt cookie for admin 2
When the client sends GET /v1/builtin-type/<key>
Then the response status is 200
And the field keys, in order, are <fields>
And no field key is one of id, created_at, updated_at, password, password_reset_code, verification_code, token_version
Examples:
| key | fields |
| article | title, body, slug, excerpt, seo_title, seo_description, is_online, canonical_url, noindex |
| author | name, slug, bio, website |
| site | name, key, base_url, default_robots, locale, is_default, site_name, title_template, default_description |
| media | filename, size, width, height, content_type |
| user | username, display_name, email, role |
Scenario Outline: Selected built-in field rules
Given a valid jwt cookie for admin 2
When the client sends GET /v1/builtin-type/<key>
Then the field <field> has type <type>, required <required>, min <min>, max <max> and options <options>
Examples:
| key | field | type | required | min | max | options |
| article | title | text | true | 3 | 200 | [] |
| article | body | richtext | false | null | null | [] |
| article | excerpt | text | false | null | 300 | [] |
| article | is_online | boolean | false | null | null | [] |
| site | default_robots | select | false | null | null | index, noindex |
| site | site_name | text | false | null | 100 | [] |
| site | title_template | text | false | null | 200 | [] |
| site | default_description | text | false | null | 320 | [] |
| user | role | select | true | null | null | owner, admin |
| user | email | text | true | null | null | [] |
Scenario: Reading one built-in type does not collide with a custom type of the same key
Given the custom content type "builtin" exists, and a custom content type "article" exists
And a valid jwt cookie for admin 2
When the client sends GET /v1/content-type/builtin and GET /v1/content-type/article
Then each response is the custom content type
When the client sends GET /v1/builtin-type/article
Then the response is the built-in article type
And GET /v1/content-type never lists a built-in type
Scenario Outline: The built-in routes are read-only and restricted
Given <caller>
When the client sends <method> <path>
Then the response status is <status>
And the response body is <message>
Examples:
| caller | method | path | status | message |
| owner 1 cookie | GET | /v1/builtin-type | 200 | |
| admin 2 cookie | GET | /v1/builtin-type/user | 200 | |
| an active API key of admin 2 | GET | /v1/builtin-type | 200 | |
| a cookie for unverified 4 | GET | /v1/builtin-type | 403 | apperr.VerificationRequiredErrorMessage |
| no credentials | GET | /v1/builtin-type | 401 | the existing Authenticator response |
| a stale token_version cookie | GET | /v1/builtin-type | 401 | the existing Authenticator response |
| admin 2 cookie | GET | /v1/builtin-type/nope | 404 | builtin_type_not_found |
| admin 2 cookie | GET | /v1/builtin-type/Article | 404 | builtin_type_not_found |
| admin 2 cookie | POST | /v1/builtin-type | 405 | empty |
| admin 2 cookie | PUT | /v1/builtin-type/article | 405 | empty |
| admin 2 cookie | DELETE | /v1/builtin-type/article | 405 | empty |
Scenario: Reading the built-in types never touches data
Given fake repositories for content types, entries, articles, authors, sites, media and users
When the client sends GET /v1/builtin-type and GET /v1/builtin-type/site
Then no repository method is called
Scenario: Any verified user resolves display names
Given a valid jwt cookie for verified user 3
When the client sends GET /v1/user/lookup?ids=1,2,3
Then the response status is 200
And the body is a JSON array of 3 items ordered by id
And each item has exactly the keys id and display_name
And the display names are "Marta Alves", "dev" (the username, because the display name is empty) and "Sam Roy"
And the body contains no email, role, username or status
Scenario Outline: Lookup edge cases
Given a valid jwt cookie for verified user 3
When the client sends GET /v1/user/lookup<query>
Then the response status is <status>
And the response body is <message>
Examples:
| query | status | message |
| ?ids=2,2,2 | 200 | an array with one item for id 2 |
| ?ids=1,999 | 200 | an array with only id 1 |
| ?ids=999 | 200 | [] |
| ?ids=4 | 200 | an array with the item for id 4 |
| (no query) | 400 | apperr.BodyErrorMessage |
| ?ids= | 400 | apperr.BodyErrorMessage |
| ?ids=abc | 400 | apperr.BodyErrorMessage |
| ?ids=1,-2 | 400 | apperr.BodyErrorMessage |
| ?ids=1,4294967296 | 400 | apperr.BodyErrorMessage |
| ?ids=1,,2 | 400 | apperr.BodyErrorMessage |
| 101 distinct ids | 400 | apperr.BodyErrorMessage |
| exactly 100 ids | 200 | an array of the existing users |
Scenario Outline: Lookup needs a verified signed-in user or an API key
Given <caller>
When the client sends GET /v1/user/lookup?ids=1
Then the response status is <status>
Examples:
| caller | status |
| a cookie for verified user 3 | 200 |
| an active API key of admin 2 | 200 |
| no credentials | 401 |
| a cookie for unverified 4 | 403 |
| a stale token_version cookie | 401 |
Scenario: Existing user and entry responses are unchanged
Given a valid jwt cookie for verified user 3
When the client sends GET /v1/user/ and GET /v1/content-type/recipe/entry
Then the responses keep their existing shapes, and created_by and updated_by stay numeric ids
Scenario: Use case: the icon rule returns a domain sentinel
Given a fake contenttype.Repository
When CreateContentType and UpdateContentType are called with each invalid icon of the table above
Then errors.Is(err, contenttype.ErrInvalidIcon) is true and the repository received no Create or Update
Scenario: Use case: the catalogue and the lookup
Given the builtintype package and a fake user.Repository
When ListBuiltinTypes and GetBuiltinType("nope") are called
Then the list has 5 types and the unknown key returns builtintype.ErrNotFound
When LookupUserNames is called with ids 1, 2, 999
Then it returns the display name for 1, the username for 2, and nothing for 999
@@ -0,0 +1,91 @@
# QA procedure: custom content types support (icon, built-in read model, user lookup)
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. Reuse the existing fakes `mockContentTypeRepo`, `mockEntryRepo`, `mockUserRepo`, `stubHasher` and the API key
fakes from the custom content types tests. Extend `mockUserRepo` with the batch lookup by ids (unknown ids
skipped). Add a counting wrapper (`countingRepos`) that records any call on the article, author, site,
media, user, content type and entry fakes, with a unique type name across the package.
3. Build the `*api.UseCases` container with fakes and mount `api.InitBackendRoutes(container, settings)` on a
test server. If a nil use case breaks a constructor, fall back to a fresh `chi.NewRouter()` mirroring
`NewContentTypeRouter`, a new `NewBuiltinTypeRouter` (same chain plus `RequireOwnerOrAdmin`) and the user
router, with `jwtauth.New("HS256", []byte("test-secret"), nil)`.
4. Inject a fake clock into the use cases.
5. Seed users: owner 1 "marta" (display name "Marta Alves"), admin 2 "dev" (empty display name), verified
id 3 "sam" ("Sam Roy"), unverified 4 "quinn". Only the roles owner and admin exist, so no verified user is
rejected by `RequireOwnerOrAdmin`. Helpers: `cookieFor(userId, tokenVersion)`, `bearer(raw)`; the API key
through `POST /v1/api-key`.
6. Reuse `sampleRecipe()`; add `withIcon(body, value)` that sets, omits or nulls the icon key.
## Icon
7. Create with `"icon": "chef-hat"` -> 201; key set is exactly `id`, `key`, `name`, `icon`, `fields`, `created_at`,
`updated_at`; the icon is also present in `GET /{key}` and in the list. Update the existing pair's
"exactly the keys" wording and its QA step 7 accordingly.
8. Create with the key absent, `null` and `""` -> 201 and `icon` is JSON `null` in all three reads (assert the
raw JSON `null`, not a missing key and not `""`).
9. Legacy row: seed `mockContentTypeRepo` with a type that has no icon set; GET list and GET one -> 200, `icon`
null, other keys unchanged.
10. Invalid icons (`Chef-Hat`, `1star`, `-star`, `chef hat`, `chef_hat`, 41 characters, JSON number `5`) ->
400 `invalid_icon`, repository empty. 1 and 40 character icons -> 201.
11. PUT on a type that has an entry: new icon -> 200; absent -> 200 and null; `null` -> 200 and null; invalid ->
400 `invalid_icon` and the stored icon is unchanged. The stored entry and its `updated_at` are unchanged in
every case.
12. Use case level: `CreateContentType` / `UpdateContentType` with each invalid icon ->
`errors.Is(err, contenttype.ErrInvalidIcon)`, no `Create`/`Update` call on the fake. Domain table test of the
icon rule on the function directly.
13. Persistence mapper round trip (unit, no GORM): icon nil and non-nil survive the model/domain conversion in
both directions. The repository `Update` select list must include the icon (verified in the manual section
against the real DB).
## Built-in types
14. Owner `GET /v1/builtin-type` -> 200, array of 5, keys `article`, `author`, `site`, `media`, `user` in order,
names `Articles`, `Authors`, `Sites`, `Media`, `Users`; each item has exactly `key`, `name`, `fields`; each
field has exactly the nine field keys; every type is one of the six engine types.
15. Field-key table (feature table): per type, the ordered field keys; assert none of the forbidden keys
(`id`, `created_at`, `updated_at`, `password`, `password_reset_code`, `verification_code`, `token_version`)
appears in any type.
16. Rules table (feature table): spot-check type, required, min, max and options for the listed fields, with
`default`/`min`/`max` as JSON `null` and `options` as `[]` when unset. Rule values come from the request
structs; where a rule is not stated there (author bio max, media filename), leave min/max null.
17. Cross-check against the real contracts so the catalogue can't drift: for each built-in type, the JSON object
keys of the domain struct (via `json.Marshal` of the entity: `article.Article`, `author.Author`, `site.Site`,
`image.Image`, `user.User`) must contain every catalogue field key, using the json tags. Fail the test if a
catalogue key does not exist in the entity JSON.
18. `GET /v1/builtin-type/{key}` for each key -> 200 with the same item as in the list; `nope` and `Article` ->
404 `builtin_type_not_found`.
19. Collision: seed custom types `builtin` and `article`; `GET /v1/content-type/builtin` and `/article` return the
custom types; `GET /v1/content-type` lists only the custom ones; `GET /v1/builtin-type/article` returns the
built-in one.
20. Access table: owner 200, admin 200, API key of admin 200, user 4 403 `apperr.VerificationRequiredErrorMessage`,
none 401, stale 401. POST/PUT/DELETE on `/v1/builtin-type` and `/v1/builtin-type/article` -> 405 for admin.
(A verified non-owner non-admin 403 cannot be tested: no such role exists.)
21. Isolation: with the counting wrapper, calling the list and `GET /site` leaves every repository call count at
zero.
22. Use case level: `ListBuiltinTypes` returns 5 types; `GetBuiltinType("nope")` returns `builtintype.ErrNotFound`.
## User lookup
23. As verified user 3, `GET /v1/user/lookup?ids=1,2,3` -> 200; array ordered by id; each item has exactly `id`
and `display_name`; names `Marta Alves`, `dev`, `Sam Roy`; raw body contains none of `email`, `role`,
`username`, `status`, `password`.
24. Edge table (feature table): duplicates collapse, unknown ids are omitted, all-unknown -> `[]` (assert JSON
`[]` not `null`), unverified id 4 still resolves, missing/empty/non-numeric/negative/overflow/`1,,2`/101 ids
-> 400 `apperr.BodyErrorMessage`, exactly 100 ids -> 200.
25. Access table: verified user 3 200, API key of admin 2 200, none 401, user 4 403, stale token_version 401.
26. Regression: `GET /v1/user/` and entry responses are unchanged (same keys as before, `created_by`/`updated_by`
numeric).
27. Use case level: `LookupUserNames` with a fake `user.Repository` returns display name, falls back to the
username when it is empty, omits unknown ids, and makes a single batch call on the fake (no per-id lookups).
## Manual verification (not exercised by the Go tests)
- Start `task dev:api` on an existing database that already has content types: confirm AutoMigrate adds the
nullable icon column, old types read `icon: null`, and nothing else changes. Repeat on MySQL/MariaDB and
PostgreSQL via the existing driver tasks.
- Change an icon through PUT against the real SQLite file and reload: confirms the repository `Update` select
list includes the icon (the mapper test cannot).
- Compare `GET /v1/builtin-type/<key>` field rules with the real `POST`/`PATCH` validation of each resource once
(for example create a site with a 101 character `site_name`, expect a 400) to confirm that the stated max
values are honest.
- Request `GET /v1/user/lookup` in a browser session as a non-admin and confirm no email or role appears in the
network response.