feat(API): media library

This commit is contained in:
fsylvain committed 2026-09-30 18:35:28 +02:00
1 parent 95ae043d3e
commit 0d99544579
65 files changed
+3992 -1530

No files matched your search

@@ -1,84 +0,0 @@
Feature: Deleting an article also deletes its images
As an authenticated RenewCMS user managing the CMS
I want deleting an article to also delete every image attached to it
So that removing an article doesn't leave orphaned image rows and files behind,
and so that an id that doesn't exist still answers 404 the same way every other
article write does
Background:
Given internal/domain/article.Article carries Images []*image.Image, populated by
ArticleRepository.Get via Preload("Images")
And internal/domain/image.Repository already exposes Delete(id uint32) error, which
on the real ImageRepository also removes the uploaded file from UPLOAD_DIR
And before this slice, DeleteArticleUseCase.DeleteArticle only called
articleRepository.Delete(id) and never touched image.Repository, so an article's
images were never deleted along with it
And this slice changes NewDeleteArticleUseCase to also take an image.Repository, and
changes DeleteArticleUseCase.DeleteArticle(id) to look up the article, delete every
image in its Images slice through image.Repository.Delete, and only then delete the
article itself through article.Repository.Delete
And this slice rewires api/dependecyInjection.go so
useCases.NewDeleteArticleUseCase(repos.ArticleRepo, repos.ImageRepo) supplies both
repositories
And DELETE /v1/article/{id} keeps sitting behind the jwtauth Verifier and
Authenticator group in api/router.go, unchanged from
features/write_not_found_errors.feature
And DELETE /v1/article/{id} keeps answering 204 with an empty body on success and 404
with body apperr.ArticleNotFoundErrorMessage for an id absent from the article
repository, backed by errors.Is(err, article.ErrArticleNotFound), unchanged from
features/write_not_found_errors.feature
And no image HTTP endpoint is mounted in api/router.go as of this slice, so cascading
image deletion is only observable through the image repository's state, not through
a separate HTTP call
And the use case deletes images in the order they appear in the article's Images
slice, and aborts on the first image.Repository.Delete error, leaving the article
and any not-yet-deleted images untouched, and answering the same generic 500 the
DeleteArticle handler already falls back to on any non-not-found error
Scenario: Deleting an article with images removes the article and every one of its images
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 whose Images slice holds
image id 10 and image id 11
And the image repository contains images with id 10 and id 11
When the client sends DELETE /v1/article/1 with that cookie
Then the response status is 204
And the article repository no longer contains an article with id 1
And the image repository no longer contains an image with id 10 or id 11
Scenario: Deleting an article with no images still succeeds
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 2 whose Images slice is empty
When the client sends DELETE /v1/article/2 with that cookie
Then the response status is 204
And the article repository no longer contains an article with id 2
And no call reaches the image repository's Delete method
Scenario: Deleting an unknown article still answers 404 and deletes no images
Given a valid jwt cookie for a user with id 1
And the article repository has no article with id 9999
When the client sends DELETE /v1/article/9999 with that cookie
Then the response status is 404
And the response body is the text "Article not found"
And no call reaches the image repository's Delete method
Scenario: Deleting an article still requires authentication
Given no jwt cookie is sent
And the article repository contains an article with id 1 whose Images slice holds
image id 10
When the client sends DELETE /v1/article/1 without a cookie
Then the response status is 401
And the article repository still contains the article with id 1
And the image repository still contains the image with id 10
Scenario: A failure deleting one of an article's images aborts the whole delete
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 3 whose Images slice holds
image id 20 followed by image id 21
And the image repository contains images with id 20 and id 21
And the image repository's Delete fails for image id 20
When the client sends DELETE /v1/article/3 with that cookie
Then the response status is 500
And the response body is the text "Failed to delete article"
And the article repository still contains the article with id 3
And the image repository still contains the image with id 21
@@ -1,67 +0,0 @@
# QA procedure: delete_article_cascades_images
Package: `test` (black-box, extending `article_test.go`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` (`test/article_test.go`) and `mockImageRepo`
(`test/image_test.go`) as-is. Extend `mockArticleRepo` seeding so a seeded article's
`Images []*image.Image` can hold pointers into the same `mockImageRepo.images` map used
by the test, so deleting through the article side and asserting through the image side
observe the same state.
3. Add a call counter (or reuse the existing `failSwitch`/call-tracking pattern already in
`test/`) on `mockImageRepo.Delete` so a scenario can assert it was never called, and reuse
`mockImageRepo.fail` (the existing `failSwitch`) to make `Delete` fail for a specific
image id.
4. Wire the real use cases over the fakes: `useCases.NewCreateArticleUseCase`,
`useCases.NewGetArticleUseCase`, `useCases.NewListArticlesUseCase`,
`useCases.NewUpdateArticleUseCase`, and the new two-argument
`useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)`. Build `article.NewHandler(...)`
with the same constructor shape it already has (the use case's internal dependency count
changes, not the handler's).
5. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
6. Mount the routes on a fresh `chi.NewRouter()` mirroring `api/router.go`'s current shape:
`GET /` under `jwtauth.Verifier` only; `GET /{id}`, `POST /`, `DELETE /{id}`,
`PATCH /{id}`, `PATCH /{id}/publish`, `PATCH /{id}/unpublish` under `jwtauth.Verifier` +
`jwtauth.Authenticator`.
7. Mint a valid jwt cookie for user id 1: `token.Encode(map[string]any{"user_id": uint32(1)})`,
set as the `jwt` cookie on the relevant `httptest.NewRequest`.
## HTTP checks
8. Seed `imageRepo.images[10]` and `imageRepo.images[11]`, then seed
`articleRepo.articles[1] = article.Article{ID: 1, Title: "Backyard Renovation Update",
Images: []*image.Image{imageRepo.images[10], imageRepo.images[11]}}` (pointers into the
same map). Send `DELETE /v1/article/1` with the valid cookie -> status `204`; assert
`articleRepo.articles` no longer has key `1`; assert `imageRepo.images` no longer has
keys `10` or `11`.
9. Seed `articleRepo.articles[2] = article.Article{ID: 2, Title: "Draft: Kitchen Remodel"}`
with a nil/empty `Images` slice. Send `DELETE /v1/article/2` with the valid cookie ->
status `204`; assert `articleRepo.articles` no longer has key `2`; assert the image-delete
call counter is `0`.
10. Empty `articleRepo`, empty `imageRepo`. Send `DELETE /v1/article/9999` with the valid
cookie -> status `404`, body `"Article not found\n"`; assert the image-delete call
counter is `0`.
11. Seed `imageRepo.images[10]` and `articleRepo.articles[1]` with `Images` holding image id
10, per step 8's pattern. Send `DELETE /v1/article/1` with **no** cookie -> status `401`;
assert `articleRepo.articles[1]` is still present and `imageRepo.images[10]` is still
present.
12. Seed `imageRepo.images[20]` and `imageRepo.images[21]`, then seed
`articleRepo.articles[3] = article.Article{ID: 3, Title: "Kitchen Progress Photos",
Images: []*image.Image{imageRepo.images[20], imageRepo.images[21]}}` (image 20 before
image 21 in the slice). Arm `imageRepo.fail` so `Delete` returns an error only when
called with id `20` (leave id `21` unarmed). Send `DELETE /v1/article/3` with the valid
cookie -> status `500`, body `"Failed to delete article\n"`; assert
`articleRepo.articles[3]` is still present; assert `imageRepo.images[21]` is still
present (never reached because the delete aborted on image `20` first).
## Manual verification (not exercised by the Go test)
- Real cascading delete against a real SQLite file and real `UPLOAD_DIR`: confirm that after
`DELETE /v1/article/{id}` on a real running instance, the uploaded image files under
`UPLOAD_DIR` for that article are gone from disk and the `images` table no longer holds
those rows, and that no orphaned rows are left behind in the `article_images` join table.
Not exercised by `task test` (fakes only); requires `task dev` or a built `task build`
binary with a scratch DB/upload dir.
- SPA-visible behaviour: confirm the back-office article deletion flow (if/when one exists)
reflects that any images shown for that article are gone too. No such UI exists yet as of
this slice.
- No SMTP involvement.
@@ -0,0 +1,39 @@
Feature: Deleting an article never deletes images
As an authenticated RenewCMS user
I want deleting an article to leave the media library alone
So that an image used by other articles, or waiting in the library, is never lost
Background:
Given images belong to the library and internal/domain/article.Article carries no Images
And DeleteArticleUseCase depends only on article.Repository and calls its Delete
And api/dependecyInjection.go builds useCases.NewDeleteArticleUseCase(repos.ArticleRepo)
And DELETE /v1/article/{id} stays behind the same jwtauth group, answering 204 with an
empty body, 404 with apperr.ArticleNotFoundErrorMessage for errors.Is(err,
article.ErrArticleNotFound), and the generic 500 "Failed to delete article" otherwise
Scenario: Deleting an article leaves every image it used
Given a valid jwt cookie for user 1
And article 1 whose body contains the urls of images 10 and 11
And the image repository contains images 10 and 11
When the client sends DELETE /v1/article/1
Then the response status is 204
And the article repository no longer contains article 1
And the image repository still contains images 10 and 11 and Delete was never called
Scenario: Deleting an unknown article is 404 and touches no image
Given a valid jwt cookie for user 1 and no article 9999
When the client sends DELETE /v1/article/9999
Then the response status is 404 and the body is "Article not found"
And the image repository is untouched
Scenario: Deleting still requires authentication
Given no jwt cookie
When the client sends DELETE /v1/article/1
Then the response status is 401 and article 1 and image 10 still exist
Scenario: An article repository failure is 500 and everything stays
Given the article repository's Delete fails
When the client sends DELETE /v1/article/1 with a valid cookie
Then the response status is 500 and the body is "Failed to delete article"
And the image repository is untouched
@@ -0,0 +1,12 @@
# QA procedure: delete_article_keeps_images
Package: `test` (black-box). Wire `useCases.NewDeleteArticleUseCase(articleRepo)` (single argument) with the other article use cases and mount as in the retired `delete_article_cascades_images.qa.md`: `Verifier` plus `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, jwt cookie for user id 1. Seed images 10 and 11 in `mockImageRepo` and the article body with their urls. Call `validator.InitValidator()` first.
## HTTP checks
1. `DELETE /v1/article/1` with a valid cookie -> 204, `articleRepo` no longer holds article 1, `imageRepo.images` still holds 10 and 11 and `deleteCalls` is empty.
2. `DELETE /v1/article/9999` -> 404, body `"Article not found\n"`, image repo untouched.
3. `DELETE /v1/article/1` without cookie -> 401, article and images still present.
4. Article fake's `Delete` armed to fail -> 500, body `"Failed to delete article\n"`, image repo untouched.
## Manual verification
With a real SQLite file and `UPLOAD_DIR`, delete an article and confirm the image files and rows remain and still load. No SMTP.
+2 -2
View File
@@ -17,8 +17,8 @@ Feature: Allowed image types and the size limit are a domain rule
the matching sentinel and calling the repository not at all when either check fails
And ImageRepository.Create no longer rejects an unrecognized content type itself; it
only maps an already-allowed content type to a file extension
And no image HTTP endpoint is mounted in api/router.go as of this slice, so this
behavior is observable only through CreateImageUseCase
And the HTTP mapping of these sentinels is 413, 415 and 400, specified in
features/media_library.feature
Scenario Outline: CreateImage accepts every allowed content type within the size limit
Given an empty image repository fake
+391
View File
@@ -0,0 +1,391 @@
Feature: The media library holds every uploaded image as a library-level asset
As a verified RenewCMS user
I want to upload, browse, rename, delete, merge and inspect images that belong to the
library and not to any article
So that one image can be used in many articles, deleting an article never deletes an image,
and duplicates can be merged without leaving a broken image behind
Background:
Given internal/domain/image.Image carries ID, Path, Filename, Size, ContentType, Width,
Height (0 for SVG), SHA256, UploadedByID, CreatedAt and UpdatedAt, and no article id
And image.ImageInput gains Width, Height, SHA256 and UploadedByID
And internal/domain/image defines the sentinels ErrImageUnreadable, ErrInvalidFilename,
ErrMergeCopyMissing and ErrMergeHashMismatch, plus the existing ErrImageNotFound,
ErrImageTypeNotAllowed and ErrImageTooLarge, all asserted with errors.Is
And image.Repository exposes Create, Get, Delete, List(query), FindByHash, Rename,
ListByHash groups and Counts, and article.Repository exposes
FindByBodyContaining(filename) and the existing UpdateBody, and no longer AddImage
And CreateImageUseCase reads the bytes fully, rejects a type outside image/png, image/jpeg,
image/webp and image/svg+xml with ErrImageTypeNotAllowed, rejects more than 10485760
bytes actually read with ErrImageTooLarge, computes SHA-256 hex and, for raster images,
width and height from the bytes, and rejects bytes that cannot be decoded (or an SVG
without an svg root) with ErrImageUnreadable, all before image.Repository.Create is called
And an image's "used in" is the count of articles, online or draft, whose body contains
the image's url, read from article bodies each time
And /v1/media is mounted in api/router.go behind jwtauth.Verifier, jwtauth.Authenticator,
middleware.RequireCurrentToken and middleware.RequireVerifiedUser
And every media item is JSON with snake_case fields:
{"id","filename","url","size","content_type","width","height","sha256","uploaded_by",
"created_at","used_in","duplicate"}, where url is the stored path
"/static/uploadedImages/<uuid><ext>", width and height are null for SVG, uploaded_by is
the uploader's display name (username when empty, "" when unknown) and duplicate is true
when another image has the same sha256
And request bodies are validated with validate tags and validator.Validate.Struct, and
errors are plain text from http.Error
Scenario Outline: Every media route refuses callers that are not logged in and verified
When the client sends <method> <path> <credentials>
Then the response status is <status>
And no image or article is read or changed
Examples:
| method | path | credentials | status |
| GET | /v1/media/ | no cookie | 401 |
| POST | /v1/media/ | no cookie | 401 |
| PATCH | /v1/media/1 | no cookie | 401 |
| DELETE | /v1/media/1 | no cookie | 401 |
| POST | /v1/media/merge | no cookie | 401 |
| GET | /v1/media/1/usage | no cookie | 401 |
| GET | /v1/media/ | a stale token_version cookie | 401 |
| GET | /v1/media/ | cookie of an unverified user | 403 |
| DELETE | /v1/media/1 | cookie of an unverified user | 403 |
Rule: Listing
Background:
Given user 1 has display name "Inès Duarte" and user 2 has no display name and username "marta"
And the library holds, uploaded in this order (oldest first):
| id | filename | size | content_type | width | height | sha256 | uploaded by |
| 1 | north-wall.jpg | 4089446 | image/jpeg | 3600 | 2400 | A | 1 |
| 2 | floorboards.jpg | 2516582 | image/jpeg | 3600 | 2400 | B | 1 |
| 3 | floorboards-copy.jpg | 2516582 | image/jpeg | 3600 | 2400 | B | 2 |
| 4 | window-frame.jpg | 812345 | image/jpeg | 1200 | 800 | C | 2 |
| 5 | logo.svg | 1204 | image/svg+xml | 0 | 0 | D | 1 |
And article 1 "Kitchen Renovation" is online and its body contains the urls of images 1 and 2
And article 2 "Backyard Notes" is a draft and its body contains the url of image 1
And article 3 "Empty" has a body with no image url
Scenario: The default list is newest first with used_in, duplicate and tab counts
When the client sends GET /v1/media/ with a valid cookie
Then the response status is 200
And the "items" ids are 5, 4, 3, 2, 1 in that order
And item 1 has used_in 2, duplicate false and uploaded_by "Inès Duarte"
And items 2 and 3 have duplicate true, and item 3 has uploaded_by "marta"
And item 5 has width null, height null and used_in 0
And "next_cursor" is null
And "counts" is {"all": 5, "unused": 3, "duplicates": 1}
Scenario: An empty library answers an empty list, not null
Given the library holds no image
When the client sends GET /v1/media/ with a valid cookie
Then the response status is 200
And "items" is an empty array and "counts" is {"all": 0, "unused": 0, "duplicates": 0}
Scenario Outline: sort orders the list
When the client sends GET /v1/media/?sort=<sort> with a valid cookie
Then the "items" ids are <ids> in that order
Examples:
| sort | ids |
| newest | 5, 4, 3, 2, 1 |
| oldest | 1, 2, 3, 4, 5 |
| name | 2, 3, 5, 1, 4 |
| largest | 1, 2, 3, 4, 5 |
Scenario Outline: filter narrows the list and the counts stay library-wide
When the client sends GET /v1/media/?filter=<filter> with a valid cookie
Then the "items" ids, as a set, are <ids>
And "counts" is still {"all": 5, "unused": 3, "duplicates": 1}
Examples:
| filter | ids |
| all | 1,2,3,4,5 |
| unused | 3, 4, 5 |
| duplicates | 2, 3 |
Scenario Outline: q matches the filename case-insensitively
When the client sends GET /v1/media/?q=<q> with a valid cookie
Then the "items" ids are <ids> in that order
And "counts" is still {"all": 5, "unused": 3, "duplicates": 1}
Examples:
| q | ids |
| FLOORBOARD | 3, 2 |
| wall | 1 |
| zzz | |
Scenario: q, filter and sort combine
When the client sends GET /v1/media/?q=floorboards&filter=unused&sort=oldest with a valid cookie
Then the "items" ids are 3
Scenario: hash returns only the images with that content
When the client sends GET /v1/media/?hash=<sha256 of image 2's bytes> with a valid cookie
Then the "items" ids, as a set, are 2, 3
Scenario: hash of unknown content returns no item
When the client sends GET /v1/media/?hash=<64 hex zeros> with a valid cookie
Then the response status is 200 and "items" is empty
Scenario: limit and cursor page through the list
When the client sends GET /v1/media/?limit=2 with a valid cookie
Then the "items" ids are 5, 4 and "next_cursor" is a non-empty string
When the client sends GET /v1/media/?limit=2&cursor=<that next_cursor>
Then the "items" ids are 3, 2 and "next_cursor" is a non-empty string
When the client sends GET /v1/media/?limit=2&cursor=<that next_cursor>
Then the "items" ids are 1 and "next_cursor" is null
Scenario Outline: A malformed query answers 400
When the client sends GET /v1/media/?<query> with a valid cookie
Then the response status is 400
And the response body is the text apperr.MediaQueryErrorMessage
Examples:
| query |
| filter=bogus |
| sort=bogus |
| limit=0 |
| limit=101 |
| limit=abc |
| cursor=%%% |
| hash=notahash |
Rule: Uploading
Scenario Outline: An allowed image is always stored, with its metadata and hash
Given a valid cookie for user 1 whose display name is "Inès Duarte"
When the client sends POST /v1/media/ with multipart field "file" named <name>, content type <content_type> and real <content_type> bytes
Then the response status is 201
And the body is a media item whose filename is <name>, content_type is <content_type>, size is the byte count, width and height are <width> and <height>, sha256 is the SHA-256 hex of the bytes, uploaded_by is "Inès Duarte", used_in is 0 and duplicate is false
And the item's url matches "/static/uploadedImages/<uuid><ext>"
And the image repository holds one new image with UploadedByID 1
Examples:
| name | content_type | width | height |
| north-wall.png | image/png | 3 | 2 |
| IMG_2208.jpg | image/jpeg | 3 | 2 |
| hero.webp | image/webp | 1 | 1 |
| logo.svg | image/svg+xml | null | null |
Scenario: Uploading the same bytes twice stores both and marks both as duplicates
Given the library holds image 1 with the same bytes
When the client sends POST /v1/media/ with those bytes named "north-wall-again.png"
Then the response status is 201 and the item has duplicate true
And the library now holds two images with the same sha256 and different urls
And "counts".duplicates is 1 when listed
Scenario: A filename without extension gets the canonical one, a path is stripped
When the client uploads real PNG bytes named "C:\\photos\\north wall" with content type "image/png"
Then the item filename is "north wall.png"
Scenario: A file exactly at 10485760 bytes is accepted, one byte more is 413
When the client uploads a valid PNG padded to 10485760 bytes
Then the response status is 201
When the client uploads a valid PNG padded to 10485761 bytes
Then the response status is 413
And the response body is the text "the file exceeds the maximum upload size"
And the image repository's Create was called once in total
Scenario: A disallowed content type is 415
When the client uploads "notes.pdf" with content type "application/pdf"
Then the response status is 415
And the response body is the text "the file must be a PNG, JPEG, WEBP, or SVG image"
And the image repository's Create is never called
Scenario Outline: Bytes that cannot be read as the declared type are 400
When the client uploads <name> with content type <content_type> and the bytes "not an image"
Then the response status is 400
And the response body is the text "the image could not be read"
And the image repository's Create is never called
Examples:
| name | content_type |
| broken.png | image/png |
| broken.jpg | image/jpeg |
| broken.webp | image/webp |
| broken.svg | image/svg+xml |
Scenario: A request without the "file" part is 400
When the client sends POST /v1/media/ with a multipart body that has no "file" field
Then the response status is 400
And the image repository's Create is never called
Scenario: A repository failure is a generic 500
Given the image repository's next Create fails
When the client uploads a valid PNG
Then the response status is 500 and the body is "Internal server error"
Rule: Renaming
Background:
Given image 1 is "north-wall.jpg" with url "/static/uploadedImages/aaa.jpeg" and article 1 body contains that url
Scenario: Rename changes the display name, keeps the extension and the url
When the client sends PATCH /v1/media/1 with {"filename": "north-wall-01"}
Then the response status is 200
And the body is the media item with filename "north-wall-01.jpg" and url "/static/uploadedImages/aaa.jpeg"
And article 1's body is unchanged
And a following GET /v1/media/?q=north-wall-01 returns image 1
Scenario: Surrounding whitespace is trimmed and unicode letters are allowed
When the client sends PATCH /v1/media/1 with {"filename": " Été_2026-a "}
Then the response status is 200 and the filename is "Été_2026-a.jpg"
Scenario Outline: An invalid new name is 400 and nothing changes
When the client sends PATCH /v1/media/1 with <body>
Then the response status is 400
And the response body is the text "Enter a filename." for every row except "not json"
And the image's filename is still "north-wall.jpg"
Examples:
| body |
| {"filename": ""} |
| {"filename": " "} |
| {"filename": "***"} |
| {"filename": "a.b"} |
| {"filename": "a b"} |
| {"filename": "<101 letters>"} |
| {} |
| not json |
Scenario: Renaming an unknown image is 404
When the client sends PATCH /v1/media/9999 with {"filename": "x"}
Then the response status is 404 and the body is "Image not found"
Scenario: A malformed id is 400
When the client sends PATCH /v1/media/abc with {"filename": "x"}
Then the response status is 400 and the body is apperr.IdUint32ErrorMessage
Rule: Deleting
Scenario: An image in use can be deleted and article bodies are left alone
Given image 1 is used in article 1's body
When the client sends DELETE /v1/media/1 with a valid cookie
Then the response status is 204 and the body is empty
And image 1 is gone from the repository and its file from UPLOAD_DIR
And article 1's body is byte-for-byte unchanged
Scenario: A file already missing from disk still deletes the row
Given image 4's file is not in UPLOAD_DIR
When the client sends DELETE /v1/media/4
Then the response status is 204 and image 4 is gone from the repository
Scenario: Deleting an unknown image is 404
When the client sends DELETE /v1/media/9999
Then the response status is 404 and the body is "Image not found"
Scenario: A malformed id is 400
When the client sends DELETE /v1/media/abc
Then the response status is 400 and the body is apperr.IdUint32ErrorMessage
Scenario: A repository failure is a generic 500 and the image stays
Given the image repository's Delete fails
When the client sends DELETE /v1/media/1
Then the response status is 500 and image 1 is still in the repository
Rule: Usage
Scenario: Usage lists the articles whose body contains the url
Given article 1 "Kitchen Renovation" is online and article 2 "Backyard Notes" is a draft, both containing image 1's url, and article 3 does not
When the client sends GET /v1/media/1/usage with a valid cookie
Then the response status is 200
And the body is [{"id": 1, "title": "Kitchen Renovation", "published": true}, {"id": 2, "title": "Backyard Notes", "published": false}]
Scenario: An unused image has an empty array, not null
When the client sends GET /v1/media/4/usage
Then the response status is 200 and the body is []
Scenario: An absolute url still counts as used
Given article 3's body contains "https://cms.example.com/static/uploadedImages/aaa.jpeg" for image 1
Then GET /v1/media/1/usage includes article 3
Scenario Outline: Unknown or malformed ids
When the client sends GET /v1/media/<id>/usage
Then the response status is <status> and the body is <body>
Examples:
| id | status | body |
| 9999 | 404 | Image not found |
| abc | 400 | apperr.IdUint32ErrorMessage |
Rule: Merging
Background:
Given images 2, 3 and 6 share sha256 B, image 1 has sha256 A
And article 1 body contains the urls of images 2 and 3, article 2 body contains image 3's url twice, article 3 body contains image 2's url only, and article 4 has none of them
Scenario: Merge switches bodies to the kept url and deletes the copies
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3, 6]}
Then the response status is 200
And the body is {"articles_updated": 2, "merged": 2}
And article 1's body has image 3's url replaced by image 2's url
And article 2's body has both occurrences of image 3's url replaced by image 2's url
And article 3 and article 4 are unchanged
And images 3 and 6 are gone from the repository and from UPLOAD_DIR, and image 2 remains
Scenario: Merging copies that no article uses updates zero articles
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [6]}
Then the response status is 200 and the body is {"articles_updated": 0, "merged": 1}
Scenario: A copy that is gone answers 409 and changes nothing
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3, 9999]}
Then the response status is 409 and the body is apperr.MediaMergeConflictErrorMessage
And no article body changed and images 2 and 3 still exist
Scenario: A copy that does not share the hash answers 409 and changes nothing
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3, 1]}
Then the response status is 409
And no article body changed and images 1, 2 and 3 still exist
Scenario: A kept image that is gone answers 404
When the client sends POST /v1/media/merge with {"keep_id": 9999, "merge_ids": [3]}
Then the response status is 404 and the body is "Image not found"
And nothing changed
Scenario Outline: An invalid merge request is 400 and nothing changes
When the client sends POST /v1/media/merge with <body>
Then the response status is 400 and the body is apperr.MediaMergeRequestErrorMessage
Examples:
| body |
| {"keep_id": 2, "merge_ids": []} |
| {"keep_id": 2} |
| {"merge_ids": [3]} |
| {"keep_id": 2, "merge_ids": [2, 3]} |
| {"keep_id": 2, "merge_ids": [3, 3]} |
| {"keep_id": 0, "merge_ids": [3]} |
| not json |
Scenario: A failure rewriting a body restores the bodies already rewritten
Given the article repository's UpdateBody fails for article 2
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3]}
Then the response status is 500
And article 1's body is back to its original text, article 2 is unchanged
And images 2 and 3 still exist
Scenario: A failure deleting a copy after the rewrite is 500 and the bodies stay switched
Given the image repository's Delete fails for image 3
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3]}
Then the response status is 500
And article 1's and article 2's bodies use image 2's url and image 3 still exists
@manual
Rule: Startup backfill and migration (persistence, verified by hand, see the qa.md)
Scenario: Existing rows get metadata and hash from the files on disk
Given the images table has rows created before this slice, with a path and no filename, size, content_type, width, height or sha256, and their files are in UPLOAD_DIR
When the server boots
Then each row gets filename equal to the stored file name, size, content_type, width and height (0 for SVG) and sha256 read from its file, and UploadedByID stays 0
And the old article_id column and the article_images table are kept and not written
Scenario: A row whose file is missing does not stop the boot
Given a row whose file is not in UPLOAD_DIR
When the server boots
Then the boot completes, the row is left without metadata, and the server serves requests
Scenario: The backfill is idempotent
When the server boots a second time
Then no row is changed again
Scenario: A row without metadata still lists
Then GET /v1/media/ returns it with size 0, width null, height null, sha256 "" and duplicate false
+39
View File
@@ -0,0 +1,39 @@
# QA procedure: media_library
Package: `test` (black-box). New file `test/media_library_http_test.go`. Fake type names must be unique in the package.
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Rewrite `mockImageRepo` (`test/image_test.go`) to the new `image.Repository`: Create (stores the metadata from the ImageInput and derives a `/static/uploadedImages/<n>.<ext>` path), Get, Delete (records calls, honours `failDeleteFor`), List(query), FindByHash, Rename, ListByHash groups, Counts. Add fields for seeding `Filename`, `Size`, `ContentType`, `Width`, `Height`, `SHA256`, `UploadedByID`, `CreatedAt`.
3. Extend `mockArticleRepo` (`test/article_test.go`) with `FindByBodyContaining(filename)` (substring over `Body`) and a per-id failure switch for `UpdateBody`. Remove `AddImage` and the article `Images` field from the fake and from every test that uses them.
4. Use the existing user fake so `uploaded_by` resolves: user 1 display name "Inès Duarte", user 2 username "marta" with empty display name.
5. Wire the real use cases over the fakes: create, list, rename, delete, merge and usage media use cases, `NewCreateImageUseCase(imageRepo)`, `NewGetUserUseCase(userRepo)`. Build `media.NewHandler(...)` (`api/handlers/media`) and `image.NewHandler(...)` for the alias.
6. Build `jwtauth.New("HS256", []byte("test-secret"), nil)`. Mount on a fresh `chi.NewRouter()` mirroring `api/router.go`: `jwtauth.Verifier` at the top, then in the media group `Authenticator`, `RequireCurrentToken(getUser)`, `RequireVerifiedUser(getUser)`, with the six routes under `/v1/media`.
7. Mint cookies via `token.Encode(map[string]any{"user_id": uint32(n), ...})` as in the other HTTP tests. Build a stale-token cookie with a `token_version` lower than the user's, and use an unverified user for the 403 case.
8. Build multipart bodies with `mime/multipart.Writer`, using `CreatePart` with an explicit part `Content-Type` header. Generate real bytes in the test:
- PNG (3x2) with `image/png`;
- JPEG (3x2) with `image/jpeg`;
- a tiny valid WEBP fixture (1x1) as a byte constant;
- SVG `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>`.
Compute expected sha256 in the test with `crypto/sha256`. For size limits, pad valid PNG bytes to 10485760 and 10485761 bytes.
9. Seed the Listing background exactly as in the feature (5 images, 3 articles, 2 users), with `created_at` strictly increasing by id.
## HTTP checks
10. Auth table: each method+path from the outline with no cookie -> 401, stale cookie -> 401 with `apperr.UnauthorizedErrorMessage`, unverified cookie -> 403 with `apperr.VerificationRequiredErrorMessage`. Assert no fake method beyond the middleware's user lookup was called.
11. List: decode `{items, next_cursor, counts}`. Assert each row of the sort, filter, `q` and `hash` tables, the combined query, the paging walk (limit 2 across three pages ending in `next_cursor == null`), the empty library (`items` is `[]` in the raw JSON, not `null`), SVG `width`/`height` are JSON `null` (assert on the raw body), and `counts` is unchanged by `q`/`filter`. Assert every 400 query row, body equals `apperr.MediaQueryErrorMessage + "\n"`.
12. Upload: for each allowed type assert 201, the item fields, `sha256` equals the test's own hash, `uploaded_by`, the url regex, and one repo entry with `UploadedByID == 1`. Upload identical bytes twice: both 201, second has `duplicate: true`, list `counts.duplicates == 1`. Assert filename rules (no extension gets `.png`, path stripped). Assert 413/415/400 statuses and bodies, that `Create` is never called on rejection, boundary 10485760 vs 10485761, a missing `file` part -> 400, and an armed `Create` failure -> 500 `"Internal server error\n"`.
13. Rename: assert 200 with the new filename, the same `url`, the article body unchanged, and every invalid row -> 400 with `"Enter a filename.\n"` for the filename rows; 404 for 9999; 400 with `apperr.IdUint32ErrorMessage` for `abc`. Assert the repository filename is unchanged after each 400.
14. Delete: in-use image -> 204 with an empty body, article bodies byte-identical, `deleteCalls == [1]`; 404, 400, 500 rows. The "file already gone" case is a persistence behaviour and is a manual item.
15. Usage: assert the exact JSON arrays (raw body compared as JSON), the empty `[]`, the absolute-url article, 404 and 400.
16. Merge: assert 200 `{articles_updated, merged}`, resulting bodies (both occurrences replaced), unchanged bodies for the other articles, and only image 2 left. Assert the 409 rows and 404 row, with no body change and no `Delete` calls (`deleteCalls` empty). Assert every 400 row with `apperr.MediaMergeRequestErrorMessage`. Assert the two failure rows: `UpdateBody` failing for article 2 leaves article 1 restored and no `Delete` call; `Delete` failing for image 3 leaves bodies switched and returns 500.
17. Unit-level asserts (same file, no HTTP) with `errors.Is` on the sentinels `ErrImageUnreadable`, `ErrMergeCopyMissing`, `ErrMergeHashMismatch`, `ErrInvalidFilename`.
18. Layering guard: `go list -deps` or a grep-style test that `api/handlers/media` imports no `persistence`, `mailer`, `models` or `gorm` package.
## Manual verification (not exercised by `task test`)
- Real upload, rename, delete, merge against a scratch SQLite file and `UPLOAD_DIR` via `task dev`: the files land on disk, `url` loads through `/static/uploadedImages/`, deleting an in-use image leaves the article body text untouched, and a merge removes the copy files.
- Delete when the file was already removed from `UPLOAD_DIR` still answers 204.
- Startup backfill: copy an old database (rows with only `path`), boot, and check that filename, size, content_type, width, height and sha256 are filled; check idempotency on a second boot; check that a row whose file is missing does not stop the boot; check that the `article_images` table and `article_id` column are still present and not written.
- `UploadedByID` 0 rows display an empty `uploaded_by`.
- WEBP dimensions with a real WEBP file (the fixture is minimal).
- No SMTP involvement. No real rate limit (out of scope for this slice).
-99
View File
@@ -1,99 +0,0 @@
Feature: Removing an image from an article
As an authenticated RenewCMS user managing the CMS
I want to delete an already-uploaded image by its id
So that a removed image's file and row are actually gone, and a delete on
an unknown article or an unknown image answers 404 the same way every
other article write does
Background:
Given internal/infrastructure/useCases.DeleteImageUseCase.DeleteImage(imageId)
already exists and calls image.Repository.Delete(imageId), which returns
image.ErrImageNotFound for an unknown id and, on the real ImageRepository,
also removes the uploaded file from UPLOAD_DIR, per
features/delete_article_cascades_images.feature
And this slice changes POST /v1/article/{id}/image's success response
(api/handlers/image.Handler.PostImage, features/upload_article_image.feature)
to also return the created image's numeric id, so the 201 body becomes
{"id": ..., "location": ...}; this lets a client address the image later
through this slice's DELETE route
And this slice mounts DELETE /v1/article/{articleId}/image/{imageId} in
api/router.go, in the same jwtauth Verifier + Authenticator group as
POST .../image
And this slice adds a DeleteImage method to api/handlers/image.Handler,
grows NewHandler's signature to also take DeleteImageUseCase, and wires
it in api/router.go
And DeleteImage parses articleId and imageId from the URL, answering 400
with apperr.IdUint32ErrorMessage if either isn't a valid uint32
And DeleteImage first calls GetArticleUseCase.GetArticle(articleId);
errors.Is(err, article.ErrArticleNotFound) answers 404 with
apperr.ArticleNotFoundErrorMessage; this only confirms the article id
in the URL names a real article, and does not verify that the image id
belongs to that article's Images slice (a shallow existence check on
both ids, not an ownership check)
And DeleteImage then calls DeleteImageUseCase.DeleteImage(imageId);
errors.Is(err, image.ErrImageNotFound) answers 404 with the new
apperr.ImageNotFoundErrorMessage = "Image not found"; any other error
falls back to the existing generic 500
And on success DeleteImage answers 204 with an empty body, the same shape
as DELETE /v1/article/{id}
Scenario: Removing an image from an article it belongs to succeeds
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 with that cookie
Then the response status is 204
And the response body is empty
And the image repository no longer contains an image with id 10
Scenario: Removing an image from an unknown article answers 404 and never touches the image repository
Given a valid jwt cookie for a user with id 1
And the article repository has no article with id 9999
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/9999/image/10 with that cookie
Then the response status is 404
And the response body is the text "Article not found"
And the image repository's Delete is never called
And the image repository still contains the image with id 10
Scenario: Removing an unknown image from an existing article answers 404
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update"
And the image repository has no image with id 9999
When the client sends DELETE /v1/article/1/image/9999 with that cookie
Then the response status is 404
And the response body is the text "Image not found"
Scenario: Removing an image id that belongs to a different article still succeeds, because the check is shallow
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice is empty
And the article repository contains a second article with id 2 whose
Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 with that cookie
Then the response status is 204
And the image repository no longer contains an image with id 10
Scenario: Removing an image still requires authentication
Given no jwt cookie is sent
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 without a cookie
Then the response status is 401
And the image repository still contains the image with id 10
Scenario Outline: A malformed id in the URL answers 400 before either repository is touched
Given a valid jwt cookie for a user with id 1
When the client sends DELETE /v1/article/<article_id>/image/<image_id> with that cookie
Then the response status is 400
And the response body is the text "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
Examples:
| article_id | image_id |
| abc | 10 |
| 1 | abc |
-56
View File
@@ -1,56 +0,0 @@
# QA procedure: remove_article_image
Package: `test` (black-box, extending `article_test.go` / `image_test.go`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` and `mockImageRepo` as-is.
3. Wire `useCases.NewDeleteImageUseCase(imageRepo)` (already used by
`delete_article_cascades_images.qa.md`'s suite) alongside the existing
use cases.
4. Build `image.NewHandler(...)` with its grown constructor including the
delete use case, matching whatever parameter order the coder settles on.
5. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
6. Mount `DELETE /{id}/image/{imageId}` alongside the existing article
routes under the authenticated group, mirroring `api/router.go` after
this slice.
7. Mint a valid jwt cookie for user id 1 as in `upload_article_image.qa.md`.
## HTTP checks
8. Seed `articleRepo.articles[1]` with `Images: []*image.Image{{ID: 10}}`
and `imageRepo.images[10] = image.Image{ID: 10}`. Send
`DELETE /v1/article/1/image/10` with the valid cookie. Assert status
204, empty body; assert `imageRepo.images` no longer has key 10.
9. Empty `articleRepo`; seed `imageRepo.images[10]`. Send
`DELETE /v1/article/9999/image/10`. Assert 404, body
`"Article not found\n"`; assert `imageRepo.images` still has key 10
(Delete never called — track via a call-count field on the fake, the
same way the existing fakes already track `Create`).
10. Seed `articleRepo.articles[1]`; empty `imageRepo`. Send
`DELETE /v1/article/1/image/9999`. Assert 404, body
`"Image not found\n"`.
11. Seed `articleRepo.articles[1]` (empty Images) and `articleRepo.articles[2]`
with `Images: []*image.Image{{ID: 10}}`; seed `imageRepo.images[10]`.
Send `DELETE /v1/article/1/image/10`. Assert 204; assert
`imageRepo.images` no longer has key 10 — asserts the shallow-check
contract from the feature's fourth scenario.
12. Seed as in step 8. Send `DELETE /v1/article/1/image/10` with **no**
cookie. Assert 401; assert `imageRepo.images` still has key 10.
13. Examples-driven: send `DELETE /v1/article/abc/image/10` and
`DELETE /v1/article/1/image/abc` with the valid cookie. Assert 400,
body `"The server expects the ID to be in the format of an unsigned
32-bit integer (uint32).\n"` for each.
## Also touched by this slice: upload_article_image's 201 body
14. Extend `upload_article_image.qa.md`'s existing 201-body assertion to
also decode and check the `id` field equals the newly created image's
`ID` in `imageRepo.images`.
## Manual verification (not exercised by the Go test)
- Real DELETE against a real SQLite file and real UPLOAD_DIR: confirm the
file is actually removed from disk and the `article_images` join row is
gone, the same way `upload_article_image.qa.md`'s manual item covers the
POST side.
- SPA-visible behaviour: covered separately by
`features/editor_image_removal.qa.md`.
- No SMTP involvement.
+38 -90
View File
@@ -1,99 +1,47 @@
Feature: Uploading an image to an article
Feature: POST /v1/article/{id}/image is a thin alias that creates a library image
As an authenticated RenewCMS user managing the CMS
I want to attach an image file to an existing article and get back the
image's public location
So that an unknown article answers 404 the same way every other article
write does, without ever creating an orphaned image, and a disallowed
content type is rejected with 400 before it becomes part of the
article, without a maintainer needing to trust the client to send a
sane file
As the current editor, until the editor slice moves it to /v1/media
I want to keep uploading through the article route and get {id, location} back
So that the editor keeps working while images are library assets
Background:
Given internal/infrastructure/useCases.CreateImageUseCase.CreateImage
already checks the uploaded content type against the allowed list
(image/png, image/jpeg, image/webp, image/svg+xml) and the size
against the 10485760-byte maximum before calling image.Repository.Create,
returning image.ErrImageTypeNotAllowed or image.ErrImageTooLarge and
calling the repository not at all when either check fails
And internal/infrastructure/persistence's ArticleRepository.AddImage
already returns article.ErrArticleNotFound when the article id doesn't
match any row, and image.ErrImageNotFound when the article exists but
the image id doesn't, both asserted with errors.Is
And before this slice, api/handlers/image.Handler.PostImage existed and
was wired to CreateImageUseCase and UpdateArticleUseCase.AddImage, but
no route called it, and every error it got back from either use case
fell through to a blanket 500
And this slice mounts POST /v1/article/{id}/image in api/router.go, in
the same jwtauth Verifier + Authenticator group as the other article
write routes, and changes the upload flow so that the article's
existence is confirmed before the image is created, so that an
unknown article id never leaves an orphaned image behind
And this slice changes PostImage's error handling so that errors.Is(err,
image.ErrImageTypeNotAllowed) or errors.Is(err, image.ErrImageTooLarge)
answers 400 with the sentinel's own message, errors.Is(err,
article.ErrArticleNotFound) answers 404 with body
apperr.ArticleNotFoundErrorMessage, and any other error still falls
back to a generic 500
And PostImage reads the uploaded file from the multipart form field
"file"
And on success PostImage answers 201 with a JSON body
{"id": ..., "location": ...} holding the created image's numeric id
and public path, e.g. "/static/uploadedImages/<stored-filename>"
And this slice (features/remove_article_image.feature) adds the "id"
field to that body; the id lets a client later address the image
through DELETE /v1/article/{id}/image/{imageId}
Given the route stays in the article router's jwtauth group with RequireCurrentToken and
RequireVerifiedUser, and is marked for removal in the editor slice
And the handler calls GetArticleUseCase first, so an unknown article is 404
apperr.ArticleNotFoundErrorMessage and nothing is created
And it then calls the same CreateImageUseCase as POST /v1/media/, with UploadedByID from the
jwt user_id claim, attaches nothing to the article, and answers 201 {"id", "location"}
where location is the image's url
And type, size and unreadable errors map as on /v1/media/: 415, 413, 400
And DELETE /v1/article/{id}/image/{imageId} is not mounted
Scenario: Uploading an allowed image type to an existing article succeeds
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update"
When the client sends POST /v1/article/1/image with that cookie and a
multipart form field "file" holding a file named "before.png" with
content type "image/png"
Scenario: Uploading through the alias creates a library image
Given a valid jwt cookie for user 1 and article 1 "Backyard Renovation Update"
When the client sends POST /v1/article/1/image with a real PNG named "before.png"
Then the response status is 201
And the response body is JSON with a "location" field whose value is
"/static/uploadedImages/before.png"
And the response body's "id" field equals the new image's id in the
image repository
And the image repository contains a new image whose Path is
"/static/uploadedImages/before.png"
And the article repository's article with id 1 has that new image's id
in its Images slice
And the body has "id" equal to the new image's id and "location" equal to its url
And GET /v1/media/ lists the image with uploaded_by of user 1
And article 1 is unchanged
Scenario: Uploading to an unknown article answers 404 and creates no image
Given a valid jwt cookie for a user with id 1
And the article repository has no article with id 9999
When the client sends POST /v1/article/9999/image with that cookie and a
multipart form field "file" holding a file named "before.png" with
content type "image/png"
Then the response status is 404
And the response body is the text "Article not found"
Scenario: An unknown article is 404 and creates no image
When the client sends POST /v1/article/9999/image with a valid PNG
Then the response status is 404 and the body is "Article not found"
And the image repository's Create is never called
And no image exists in the image repository
Scenario: Uploading a disallowed content type answers 400 before the article is touched
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update"
When the client sends POST /v1/article/1/image with that cookie and a
multipart form field "file" holding a file named "notes.pdf" with
content type "application/pdf"
Then the response status is 400
And the response body is the text
"the file must be a PNG, JPEG, WEBP, or SVG image"
And the image repository's Create is never called
And the article repository's article with id 1 still has an empty
Images slice
Scenario Outline: Rejected files use the media status codes
When the client sends POST /v1/article/1/image with <file>
Then the response status is <status> and Create is never called
Scenario: Uploading an image still requires authentication
Given no jwt cookie is sent
And the article repository contains an article with id 1 titled
"Backyard Renovation Update"
When the client sends POST /v1/article/1/image without a cookie and a
multipart form field "file" holding a file named "before.png" with
content type "image/png"
Then the response status is 401
And the image repository's Create is never called
And the article repository's article with id 1 still has an empty
Images slice
Examples:
| file | status |
| "notes.pdf" as application/pdf | 415 |
| a valid PNG padded to 10485761 bytes | 413 |
| "broken.png" as image/png with text bytes | 400 |
Scenario: The alias still requires authentication
When the client sends POST /v1/article/1/image with no cookie
Then the response status is 401 and Create is never called
Scenario: The removed delete route is gone
When the client sends DELETE /v1/article/1/image/10 with a valid cookie
Then the response status is 405 and image 10 still exists
+12 -64
View File
@@ -4,70 +4,18 @@ Package: `test` (black-box, extending `article_test.go` / `image_test.go`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` (`test/article_test.go`) and `mockImageRepo`
(`test/image_test.go`) as-is; both already implement `AddImage` /
`Create` returning `article.ErrArticleNotFound` / `image.ErrImageNotFound`
for an unknown id, per `features/write_not_found_errors.feature`.
3. Wire the real use cases over the fakes: `useCases.NewCreateArticleUseCase`,
`useCases.NewGetArticleUseCase`, `useCases.NewListArticlesUseCase`,
`useCases.NewUpdateArticleUseCase(articleRepo)`,
`useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)`,
`useCases.NewCreateImageUseCase(imageRepo)`.
4. Build `article.NewHandler(...)` with its existing constructor shape, and
`image.NewHandler(createImageUseCase, updateArticleUseCase)`
(`api/handlers/image`).
5. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
6. Mount the routes on a fresh `chi.NewRouter()` mirroring `api/router.go`'s
shape after this slice: `GET /` under `jwtauth.Verifier` only;
`GET /{id}`, `POST /`, `DELETE /{id}`, `PATCH /{id}`,
`PATCH /{id}/publish`, `PATCH /{id}/unpublish` and the new
`POST /{id}/image` (routed to `imageHandler.PostImage`) under
`jwtauth.Verifier` + `jwtauth.Authenticator`.
7. Mint a valid jwt cookie for user id 1:
`token.Encode(map[string]any{"user_id": uint32(1)})`, set as the `jwt`
cookie on the relevant `httptest.NewRequest`.
8. Build multipart request bodies with `mime/multipart.Writer`, field name
`"file"`, setting the part's `Content-Type` header explicitly (Go's
multipart writer defaults to sniffing otherwise, so set it via
`CreatePart` with an explicit MIME header rather than
`CreateFormFile`), and set the request's `Content-Type` header to the
writer's `FormDataContentType()`.
2. Wire `image.NewHandler(createImageUseCase, getArticleUseCase)` (no update or delete use cases) and mount only `POST /{id}/image` in the article group, behind `Verifier`, `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`.
3. Use real image bytes generated in the test, as in `media_library.qa.md` step 8, and multipart bodies with an explicit part `Content-Type`.
## HTTP checks
9. Seed `articleRepo.articles[1] = article.Article{ID: 1, Title: "Backyard
Renovation Update"}`. Send `POST /v1/article/1/image` with the valid
cookie and a multipart `"file"` part named `"before.png"`,
content type `"image/png"`, some non-empty byte content. Assert status
`201`; decode the JSON body and assert `location` equals
`"/static/uploadedImages/before.png"`; assert `imageRepo.images` gained
exactly one entry whose `Path` matches that location; assert
`articleRepo.articles[1].Images` now contains that image's id.
10. Empty `articleRepo`, empty `imageRepo`. Send `POST /v1/article/9999/image`
with the valid cookie and the same `"before.png"` / `"image/png"` part.
Assert status `404`, body `"Article not found\n"`; assert
`imageRepo.images` is still empty (the article's existence is confirmed
before any image is created, so nothing is left orphaned).
11. Seed `articleRepo.articles[1] = article.Article{ID: 1, Title: "Backyard
Renovation Update"}` with an empty `Images` slice. Send
`POST /v1/article/1/image` with the valid cookie and a multipart
`"file"` part named `"notes.pdf"`, content type `"application/pdf"`.
Assert status `400`, body
`"the file must be a PNG, JPEG, WEBP, or SVG image\n"`; assert
`imageRepo.images` is still empty; assert
`articleRepo.articles[1].Images` is still empty.
12. Seed the same article id 1 as step 11. Send `POST /v1/article/1/image`
with **no** cookie and the `"before.png"` / `"image/png"` part. Assert
status `401`; assert `imageRepo.images` is still empty; assert
`articleRepo.articles[1].Images` is still empty.
4. Valid PNG on article 1 -> 201 `{id, location}` matching the created image, and `articleRepo.articles[1]` has an unchanged body.
5. Unknown article 9999 -> 404 `"Article not found\n"`, `Create` never called.
6. Rejected files: `application/pdf` -> 415, PNG padded to 10485761 bytes -> 413, text bytes as `image/png` -> 400; `Create` never called.
7. No cookie -> 401, `Create` never called.
8. `DELETE /v1/article/1/image/10` on this router -> 405, image 10 still exists.
## Manual verification (not exercised by the Go test)
- Real upload against a real SQLite file and real `UPLOAD_DIR`: confirm that
after `POST /v1/article/{id}/image` on a real running instance, the file
actually lands on disk under `UPLOAD_DIR`, the `images` row's `path`
resolves to a URL the SPA can load, and the `article_images` join table
gets the new row. Not exercised by `task test` (fakes only); requires
`task dev` or a built `task build` binary with a scratch DB/upload dir.
- SPA-visible behaviour: confirm the back-office article editor (if/when an
image-upload UI exists) shows the uploaded image at that location. No such
UI exists yet as of this slice.
- No SMTP involvement.
## Cleanup
Delete `test/remove_article_image_http_test.go` and update `test/upload_article_image_http_test.go` to the cases above.
## Manual verification
With a real instance, `POST /v1/article/{id}/image` writes a file to `UPLOAD_DIR` and a row visible in `GET /v1/media/`, and no article association is written.