feat(API): media library
This commit is contained in:
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user