392 lines
20 KiB
Gherkin
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
|