feat(API): media field type

This commit is contained in:
Florian Sylvain committed 2026-10-21 05:07:36 +02:00
1 parent ad5faf3d19
commit a2d596f4ca
63 files changed
+2567 -259

No files matched your search

+2 -2
View File
@@ -39,7 +39,7 @@ Feature: Owners and admins define custom content types, and users manage entries
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
created_at, updated_at, media, 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
@@ -201,7 +201,7 @@ Feature: Owners and admins define custom content types, and users manage entries
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 the body has exactly the keys id, content_type, data, created_by, updated_by, created_at, updated_at, media
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
+269
View File
@@ -0,0 +1,269 @@
Feature: Media field type for custom content types
As a site owner or admin
I want a content type field that holds one image from the media library
So that entries link to the library image instead of keeping a copy
Background:
Given internal/domain/contenttype adds the field type "media" next to text, richtext, number, boolean, date and select
And a media field accepts only label, key and required; unique, default, min, max and options are refused with ErrInvalidField
And a stored media value is the id of a library image (uint32, at least 1) or null
And the entry rules required and type apply to a media field; min, max, options and unique never apply
And entry.Validate stays pure; the check that an image exists in the library is done by CreateEntry and UpdateEntry through the domain image.Repository
And CreateEntry, UpdateEntry, GetEntry and ListEntries receive image.Repository, wired only in api/dependecyInjection.go
And a changed value means a value that is not equal to the id the entry holds in the stored document for that key; on POST every non-null value is changed; on PUT the value is unchanged only when the id equals the stored one
And a changed or new non-null value must be an existing library image, else 422 "invalid_entry: <key>: type"
And an unchanged stored id whose image was deleted since passes the type check
And a required media field is never saved empty: null or absent gives 422 "invalid_entry: <key>: required", and so does an unchanged stored id whose image no longer exists
And rules are checked in field order for Validate, then library existence in field order, then unique, and the body names one field per answer
And an entry response keeps its seven keys (id, content_type, data, created_by, updated_by, created_at, updated_at) and adds the key "media"
And data keeps the image id (or null) as the value of each media field
And "media" is a JSON object keyed by media field key, always present ({} when the type has no media field or every media value is null)
And "media"[key] is {"id","filename","width","height","size","thumbnail_url"} for an existing image, with width and height null when unknown, or {"id","missing":true} when the image is gone
And thumbnail_url is the image's original path, the same value as the url of a media list item
And the same "media" object is on POST 201, PUT 200, GET one and every item of the list
And usage in the library means distinct articles, authors, sites and entries; one entry counts once however many of its media fields point at the image
And GET /v1/media/ and GET /v1/media/{id} items carry used_in = articles + authors + sites + entries, and the counts.unused filter counts entries as uses
And GET /v1/media/{id}/usage stays a JSON array and gains items of type "entry"
And an entry usage item is {"type":"entry","id":<entry id>,"fields":[<media field keys>],"content_type_key":"event","content_type_name":"Events","first_field":{"key":<key>,"type":<field type>,"value":<raw stored value or null>}}, with no "title" and no "published"
And first_field is the first field of the type in schema order and its value is the raw stored JSON value, never formatted or truncated by the server; the client formats it with the same rule as the entry list and shows "Entry N" for null
And article, author and site usage items are unchanged and keep their "title"
And usage items are ordered articles by id, authors, sites, then entries by content type name then entry id; the array has no cap and the client shows the first few and the total
And the media routes keep their access rules and their plain-text errors (404 image_not_found, 400 uint32 format)
And DELETE /v1/media/{id} stays allowed for an image used by entries and leaves their data untouched
And MergeMedia (including the purge run) repoints entry media values from every merged copy to the kept image and reports it in the existing counts
And no repository method is added: entries that reference an image are found with contenttype.Repository.List and entry.Repository.ListPage (Unpaged), filtered in Go, so the code works on SQLite, MySQL/MariaDB and PostgreSQL with no JSON operators
And schema compatibility does not consult the library
And the key set assertions of the earlier custom content type and entry features and tests are amended from seven keys to eight
And types: the library images are 7 "lime-plaster.jpg" 1600x900 240000 bytes, 8 "bathroom-tiles.png" 800x600 51200 bytes; the type "event" has title (text, required) then thumbnail (media, label "Thumbnail", optional)
And users: admin id 2 (verified) can write types; any verified user can write entries
Scenario: Creating a content type with a media field
Given a valid jwt cookie for admin 2
When the client sends POST /v1/content-type with fields title (text, required) and {"key":"thumbnail","label":"Thumbnail","type":"media","required":false,"unique":false,"default":null,"min":null,"max":null,"options":[]}
Then the response status is 201
And the thumbnail field comes back with type "media", unique false, default null, min null, max null and options []
Scenario Outline: A media field refuses options it can't have
Given a valid jwt cookie for admin 2
When the client sends POST /v1/content-type with a media field that has <option>
Then the response status is 422
And the response body is "invalid_field"
And no content type is stored
Examples:
| option |
| unique true |
| default 7 |
| default "lime" |
| min 1 |
| max 5 |
| options ["a"] |
Scenario: The type of a saved media field never changes
Given the type "event" has an entry and a saved media field "thumbnail"
When admin 2 sends PUT /v1/content-type/event turning "thumbnail" into a text field
Then the response status is 422 with invalid_field and the schema is unchanged
When admin 2 sends PUT turning the text field "title" into a media field
Then the response status is 422 with invalid_field and the schema is unchanged
Scenario Outline: Adding a media field to a type that has entries
Given the type "event" has 12 entries
When admin 2 sends PUT /v1/content-type/event adding a media field "thumbnail" that is <required>
Then the response status is <status>
And the response body is <message>
And the stored schema is <outcome>
Examples:
| required | status | message | outcome |
| required | 409 | schema_incompatible | unchanged |
| optional | 200 | | updated |
Scenario: Making a media field required
Given the type "event" has a saved optional media field "thumbnail", one entry with image 7 and one entry with null
When admin 2 sends PUT making "thumbnail" required
Then the response status is 409 with schema_incompatible
When the entry holding null is deleted and the PUT is sent again
Then the response status is 200
Scenario: Making a media field required while a stored image is gone
Given the type "event" has a saved optional media field "thumbnail" and one entry holding the id of a deleted image
When admin 2 sends PUT making "thumbnail" required
Then the response status is 200
Scenario: Creating an entry with an image
Given a valid jwt cookie for admin 2
When the client sends POST /v1/content-type/event/entry with {"data":{"title":"Lime plaster workshop","thumbnail":7}}
Then the response status is 201
And data.thumbnail is 7
And media is {"thumbnail":{"id":7,"filename":"lime-plaster.jpg","width":1600,"height":900,"size":240000,"thumbnail_url":"<the image path>"}}
Scenario Outline: An empty optional media field
When the client sends POST /v1/content-type/event/entry with <data>
Then the response status is 201
And data.thumbnail is <stored>
And media is {}
Examples:
| data | stored |
| {"data":{"title":"A","thumbnail":null}} | null |
| {"data":{"title":"A"}} | absent |
Scenario Outline: Entry values that break a media field are refused and store nothing
Given the type "event" has a media field "thumbnail" that is <required>
When the client sends POST /v1/content-type/event/entry with <data>
Then the response status is 422
And the response body is <message>
And no entry exists
Examples:
| required | data | message |
| required | {"data":{"title":"A"}} | invalid_entry: thumbnail: required |
| required | {"data":{"title":"A","thumbnail":null}} | invalid_entry: thumbnail: required |
| optional | {"data":{"title":"A","thumbnail":999}} | invalid_entry: thumbnail: type |
| optional | {"data":{"title":"A","thumbnail":"7"}} | invalid_entry: thumbnail: type |
| optional | {"data":{"title":"A","thumbnail":0}} | invalid_entry: thumbnail: type |
| optional | {"data":{"title":"A","thumbnail":-1}} | invalid_entry: thumbnail: type |
| optional | {"data":{"title":"A","thumbnail":7.5}} | invalid_entry: thumbnail: type |
| optional | {"data":{"title":"A","thumbnail":4294967296}} | invalid_entry: thumbnail: type |
| optional | {"data":{"title":"A","thumbnail":true}} | invalid_entry: thumbnail: type |
| optional | {"data":{"title":"A","thumbnail":[7]}} | invalid_entry: thumbnail: type |
Scenario: Replacing the image of an entry
Given the entry 5 of type "event" holds thumbnail 7
When a verified user sends PUT .../entry/5 with {"data":{"title":"Lime plaster workshop","thumbnail":8}}
Then the response status is 200, data.thumbnail is 8 and media.thumbnail.filename is "bathroom-tiles.png"
When the user sends PUT with thumbnail null on an optional field
Then data.thumbnail is null and media is {}
Scenario: An image deleted after the entry was saved
Given the entry 5 holds thumbnail 7
When DELETE /v1/media/7 answers 204
And the client sends GET /v1/content-type/event/entry/5
Then data.thumbnail is still 7
And media is {"thumbnail":{"id":7,"missing":true}}
Scenario: An unchanged id whose image is gone can still be saved when the field is optional
Given the entry 5 holds thumbnail 7 and the image 7 was deleted
When the user sends PUT .../entry/5 with a new title and thumbnail 7
Then the response status is 200 and data.thumbnail is 7
And media.thumbnail is {"id":7,"missing":true}
Scenario Outline: A changed value must exist even when the old one is gone
Given the entry 5 holds thumbnail 7 and the image 7 was deleted
When the user sends PUT .../entry/5 with thumbnail <value>
Then the response status is <status> and the body is <message>
Examples:
| value | status | message |
| 999 | 422 | invalid_entry: thumbnail: type |
| 8 | 200 | |
| null | 200 | |
Scenario Outline: A required media field with a missing image can't be saved as is
Given the entry 5 of a type whose "thumbnail" is required holds thumbnail 7 and the image 7 was deleted
When the user sends PUT .../entry/5 with thumbnail <value>
Then the response status is <status> and the body is <message>
And the stored entry is <outcome>
Examples:
| value | status | message | outcome |
| 7 | 422 | invalid_entry: thumbnail: required | unchanged |
| null | 422 | invalid_entry: thumbnail: required | unchanged |
| 8 | 200 | | updated |
Scenario: A create always treats the value as changed
Given the image 7 was deleted
When the client sends POST with thumbnail 7
Then the response status is 422 with invalid_entry: thumbnail: type
Scenario: Two broken fields name only one
Given a body with a required field left empty and a media field pointing at a missing image
When the client sends POST
Then the response names one field, with the required and type rules of Validate checked before the library existence
Scenario: Entry list responses carry the media object per item
Given 3 entries: one with image 7, one with image 7 deleted, one with null
When the client sends GET /v1/content-type/event/entry
Then each item keeps its seven keys and adds "media"
And the items' media are one existing object, {"thumbnail":{"id":7,"missing":true}} and {}
And items, total, page and size are unchanged
Scenario: Types without a media field are not enriched
Given the type "recipe" has no media field
When the client sends GET on a recipe entry
Then the body has the seven existing keys and "media" is {}
And the image repository is not called
Scenario: Media list items count entries as uses
Given the image 8 is used by 3 entries (one of them through two media fields) and by no article, author or site
And the image 7 is used by 2 articles and 5 entries
When the client sends GET /v1/media/
Then item 8 has used_in 3 and item 7 has used_in 7
And GET /v1/media/8 has used_in 3
And ?filter=unused does not list 8 or 7, and counts.unused does not count them
Scenario: Usage of an image lists articles and entries
Given the image 7 is used by article 12 "Heat pumps" (cover) and by entries 41, 42 and 43 of "event" and entry 9 of "workshop"
When the client sends GET /v1/media/7/usage
Then the response status is 200 and the body is a JSON array
And the article item is {"type":"article","id":12,"title":"Heat pumps","published":true,"fields":["cover"]}
And the entry items follow in the order event 41, 42, 43, then workshop 9 (content type name, then id), each with exactly type, id, fields ["thumbnail"], content_type_key, content_type_name and first_field, and no title and no published
And a client counts the array for the total
Scenario Outline: The raw first field in usage
Given an entry whose first field "<key>" is of type <type> and holds <stored>
When the client reads the usage of an image the entry uses
Then the entry item first_field is {"key":"<key>","type":"<type>","value":<value>}
And no string is built by the server and the item has no "title" key
Examples:
| key | type | stored | value |
| title | text | "Lime plaster workshop" | "Lime plaster workshop" |
| body | richtext | "\n\nFirst line\n\nSecond" | "\n\nFirst line\n\nSecond" |
| seats | number | 18 | 18 |
| category | select | "Talk" | "Talk" |
| online | boolean | false | false |
| starts_on | date | "2026-11-21" | "2026-11-21" |
| title | text | null | null |
| title | text | the key absent | null |
| cover | media | 7 | 7 |
Scenario: Usage of an unused or unknown image
When the client sends GET /v1/media/8/usage with no use
Then the body is []
When the client sends GET /v1/media/999/usage
Then the response status is 404 with apperr.ImageNotFoundErrorMessage
When the client sends GET /v1/media/abc/usage
Then the response status is 400 with apperr.IdUint32ErrorMessage
Scenario: Deleting an image used by entries is allowed
Given the image 7 is used by 5 entries and 2 articles
When the client sends DELETE /v1/media/7
Then the response status is 204
And the 2 articles lose the image reference as today
And the 5 entries still hold data.thumbnail 7 and are not modified (updated_at and updated_by unchanged)
Scenario: Merging duplicates repoints entry media
Given the images 7 and 9 share the same sha256 and entries 41 and 42 point at 9
When the client sends POST /v1/media/merge with keep_id 7 and merge_ids [9]
Then the response status is 200
And entries 41 and 42 hold thumbnail 7, with created_by, updated_by and updated_at unchanged
And image 9 no longer exists
Scenario: A failure while repointing entries stops the merge
Given the entry repository fails on the second Update
When the client sends POST /v1/media/merge
Then the response status is 500, the entries already repointed are restored, and no image is removed
Scenario: The purge plan counts entries as uses
Given the duplicate group 7, 9 where only 9 is used (by 1 entry)
When the purge preview and the purge run
Then 9 is the kept image and 7 is the merged copy
Scenario: Use case level
Given fake contenttype, entry and image repositories
Then entry.Validate returns an error satisfying errors.Is(err, entry.ErrInvalidEntry) with Key "thumbnail" and Rule "required" or "type" for the shapes above
And CreateEntry and UpdateEntry return the same for a missing library image and perform no Create or Update
And UpdateContentType returns contenttype.ErrSchemaIncompatible for a new required media field and does not call Update
And contenttype.ValidateFields returns contenttype.ErrInvalidField for a media field with unique, default, min, max or options
+59
View File
@@ -0,0 +1,59 @@
# QA procedure: media field type (slice A, API only)
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP, no SPA.
## Setup shared by every case
1. Call `validator.InitValidator()` once per test.
2. Reuse the fakes of the custom content type pair (`mockContentTypeRepo`, `mockEntryRepo`) and of the media tests (image, article, site, author, variant file, storage, recorder fakes). Name any new fake uniquely across the package, for example `mockMediaFieldImageRepo`. The image fake must expose `Get`, a `getCalls` counter and a `delete(id)` helper.
3. `mockEntryRepo` needs no new method (no repository method is added). Give it an `updateCalls` counter and a `failUpdateOn(n)` switch for the merge compensation case.
4. Build `NewHandler(...)` for the entry, content type and media handlers over use cases wired to the fakes (the entry use cases now take the image fake). Mount them on a fresh `chi.NewRouter()` mirroring api/router.go: `/v1/content-type` and `/v1/media`, with `jwtauth.Verifier`, `jwtauth.Authenticator` and a test `jwtauth.New("HS256", []byte("test-secret"), nil)`. Add `RequireOwnerOrAdmin` on the content type write routes. If `api.InitBackendRoutes` with a partial `*api.UseCases` works, prefer it.
5. Seed users: admin 2 and a plain verified user. Seed images 7 "lime-plaster.jpg" (1600x900, 240000 bytes) and 8 "bathroom-tiles.png" (800x600, 51200 bytes), with distinct paths. Seed the type "event" (title text required, thumbnail media optional) and a variant with thumbnail required.
6. Inject a fake clock so `updated_at` > `created_at`.
7. Decode entry and list bodies into `map[string]any`; compare numbers as `float64`.
## Schema
8. POST a media field with the allowed shape -> 201; the field has type "media", unique false, default/min/max null, options [].
9. Table of refused options (unique true, default 7, default "lime", min 1, max 5, options ["a"]) -> 422 `invalid_field`, repo empty. Assert separately that `default: null` is accepted.
10. Type change in both directions on a type with entries -> 422 `invalid_field`, schema byte-identical. On a type with no entries the existing free type change still applies.
11. Add a media field to a type with 12 entries: required -> 409 `schema_incompatible`, schema unchanged; optional -> 200, existing entries still readable and `media` is `{}`. Make an optional media field required: 409 while an entry holds null, 200 once none does. Making it required while an entry holds an id of a deleted image -> 200 (compatibility does not consult the library).
## Entry writes
12. POST with image 7 -> 201; `data.thumbnail` is 7 and `media.thumbnail` has exactly `id`, `filename`, `width`, `height`, `size`, `thumbnail_url`; the key set of the entry is the seven existing keys plus `media`.
13. POST with null and with the key absent on an optional field -> 201, `media` is `{}` (assert JSON `{}`, not null).
14. Refusal table (required: absent, null; any: 999, "7", 0, -1, 7.5, 4294967296, true, [7]): status 422, exact body `invalid_entry: thumbnail: <rule>`, `createCalls == 0`.
15. PUT replace 7 -> 8: 200, filename "bathroom-tiles.png". PUT to null on optional: 200, `media` is `{}`.
16. After `delete(7)` on the image fake: GET entry 5 -> `data.thumbnail` 7 and `media.thumbnail` equals `{"id":7,"missing":true}` (no other key). PUT with the same 7 on an optional field -> 200, still 7 and still missing. PUT 999 -> 422 type, PUT 8 or null -> 200.
17. Required field, image 7 deleted: PUT 7 -> 422 `required`, PUT null -> 422 `required`, entry unchanged; PUT 8 -> 200.
18. POST with a deleted id 7 -> 422 `type` (create is always a changed value).
19. Ordering: a body with two bad fields names one only: Validate rules first in field order, then the existence check.
## Reads
20. List: 3 entries (existing image, deleted image, null) -> each item has the seven keys plus `media`; values as in the feature; `total`, `page` and `size` unchanged; `items` is `[]` for an empty type.
21. A type without a media field: `media` is `{}` and `getCalls == 0` on the image fake. A list of 100 entries pointing at the same image calls `Get` at most once for that id.
## Media library counts
22. Seed: image 8 used by 3 entries (one through two media fields) and no other use; image 7 used by 2 articles and 5 entries. `GET /v1/media/` -> `used_in` 3 and 7; `GET /v1/media/8` -> 3; `?filter=unused` omits both; `counts.unused` excludes both; an image with no use is still counted unused.
23. Unchanged existing cases: article, author and site usage counts and the unused counts still pass with no entries in the fake.
## Usage endpoint
24. Image 7 with article 12 (cover), entries 41, 42, 43 of "event" and entry 9 of "workshop": status 200, a JSON array, article item unchanged (keys type, id, title, published, fields). Entry items have exactly the keys `type`, `id`, `fields`, `content_type_key`, `content_type_name`, `first_field`, and no `title` and no `published`. Order: article, then event 41, 42, 43, then workshop 9.
25. Raw first-field table (text, richtext with leading blank lines, number 18, select, boolean false, date, text null, key absent, media 7). Assert `first_field` is exactly `{key, type, value}` with the value byte-equal to the stored JSON (richtext unmodified, number as a JSON number, null as JSON `null`). Assert the entry item JSON has no `title` key.
26. Unused image -> `[]`; unknown -> 404 `image_not_found` text; `abc` -> 400 `apperr.IdUint32ErrorMessage`.
## Delete and merge
27. DELETE /v1/media/7 used by 5 entries and 2 articles -> 204; articles cleared as before; the fake entry repo records no Update; the 5 entries are byte-identical.
28. Merge keep 7, merge [9] with equal sha256 and two entries on 9 -> 200; the entries hold 7; `created_by`, `updated_by` and `updated_at` are unchanged; image 9 is gone. Entries pointing at other images are untouched.
29. Merge with `failUpdateOn(2)` -> 500, the first repointed entry is restored to 9, no image deleted, articles/sites/authors repointing rolled back as today.
30. Purge preview and run on a duplicate group where only the copy is used by an entry: the used copy is the kept one; the fingerprint changes accordingly.
## Use case level (no HTTP)
31. `contenttype.ValidateFields` -> `ErrInvalidField` for each refused option. `entry.Validate` rows -> `errors.Is(err, entry.ErrInvalidEntry)` with the right key and rule. `CreateEntry`/`UpdateEntry` with a missing library image -> same error and no write. `UpdateContentType` with a new required media field -> `ErrSchemaIncompatible`, no `Update`.
## Existing tests to amend
32. Amend the existing key-set assertions from seven to eight keys in the earlier custom content type and entry feature, spec and test files (decision: `media` is always present). The coder owns this.
## Manual verification (not exercised by the Go tests)
- Real SQLite, then MySQL/MariaDB and PostgreSQL via the driver tasks: create a type with a media field, upload an image, save an entry, delete the image, reload the entry and confirm the id stays and `media` is `{missing:true}`.
- Merge duplicates with entries on a copy against real storage and confirm the entries repoint and the file is removed.
- Time `GET /v1/media/` with 1,000 entries across a type with a media field (usage lookup loads the entries of such types).
- The SPA (builder, editor control, picker, list cell, library badge and detail panel) is slices B and C and is not checked here.