feat: custom content type icons, built-in types, user lookup
This commit is contained in:
1 parent
e6e088c963
commit
73a75d5cb9
31 files changed
+1611
-36
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user