Files
RenewCMS/features/media_library.feature
T
2026-09-30 18:35:28 +02:00

392 lines
20 KiB
Gherkin

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