feat: media picker and image removal

This commit is contained in:
fsylvain committed 2026-10-01 05:04:58 +02:00
1 parent 008b0dfbb9
commit b3793d5dbb
54 files changed
+3769 -1412

No files matched your search

+10 -8
View File
@@ -9,8 +9,10 @@ Feature: Crop and resize step in the editor's image uploader
Given ImageUploader inserts a crop-and-resize step between the idle file picker and the
upload, for any JPEG, PNG or WEBP that already passed the browser validation described in
features/editor_image_upload.feature; that feature's idle, dragging-over, rejected,
uploading, inserted and failed states and its POST /v1/article/{articleId}/image contract
are unchanged and out of scope here
uploading, inserted and failed states and its POST /v1/media/ contract are unchanged and
out of scope here
And the hash lookup from features/editor_image_upload.feature runs before this step opens,
so the step only opens for a file with no match, or after "Upload anyway"
And an SVG file skips this step entirely and uploads immediately, as before
And opening the step retitles the modal from "Add an image" to "Crop and resize", with the
description "{filename} · {size}" (e.g. "north-wall.jpg · 3.9 MB")
@@ -32,8 +34,8 @@ Feature: Crop and resize step in the editor's image uploader
crop is narrower than the chosen width, an info row reads "The crop is {crop width} px
wide, so it's uploaded at that size. Enlarging it would make it blurry."
And the footer offers "Choose another file" (discards this step and returns to the idle file
picker with no adjustments carried over) and "Upload image" (uploads the cropped, resized
file through the unchanged POST /v1/article/{articleId}/image flow)
picker, with the tabs, with no adjustments carried over) and "Upload image" (uploads the cropped, resized
file through the unchanged POST /v1/media/ flow)
And "Rotate" turns the image 90° clockwise each activation, cycling 0°→90°→180°→270°→0°, and
width presets apply to the post-rotation image
And "Reset" returns the ratio to Original, the width to 1600 px, the crop frame to the whole
@@ -43,7 +45,7 @@ Feature: Crop and resize step in the editor's image uploader
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "north-wall.jpg", content type "image/jpeg", size 3.9 MB,
natural dimensions 4032 × 3024 px
Then no request is sent to POST /v1/article/12/image
Then no request is sent to POST /v1/media/
And the modal retitles to "Crop and resize" with the description "north-wall.jpg · 3.9 MB"
And the crop frame starts as the whole image, ratio "Original" is selected, and width
"1600 px" is selected
@@ -53,7 +55,7 @@ Feature: Crop and resize step in the editor's image uploader
Scenario: An SVG skips the crop step and uploads immediately
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "diagram.svg", content type "image/svg+xml", size 40 KB
Then POST /v1/article/12/image is sent immediately with that file
Then POST /v1/media/ is sent immediately with that file
And the crop-and-resize step is never shown
Scenario Outline: Choosing an aspect ratio locks the frame to it
@@ -108,7 +110,7 @@ Feature: Crop and resize step in the editor's image uploader
Scenario: "Choose another file" returns to the file picker without uploading
Given the crop-and-resize step is open for "north-wall.jpg"
When the user activates "Choose another file"
Then no request is sent to POST /v1/article/12/image
Then no request is sent to POST /v1/media/
And the idle state is shown again, with the file picker available
Scenario: A newly chosen file opens a fresh crop step, not the previous file's adjustments
@@ -123,7 +125,7 @@ Feature: Crop and resize step in the editor's image uploader
Given the crop-and-resize step is open for "north-wall.jpg" with ratio "Original", width
"1600 px" and the mono line "Uploads as 1600 × 1200 px · about 420 KB"
When the user activates "Upload image"
Then POST /v1/article/12/image is sent with a single file under the field name "file" whose
Then POST /v1/media/ is sent with a single file under the field name "file" whose
pixel dimensions are 1600 × 1200 and whose size is about 420 KB, not the original
4032 × 3024, 3.9 MB file
And the uploading state shows "Uploading 0.0 of 420 KB"
+1 -1
View File
@@ -18,7 +18,7 @@ it renders internally (canvas/OffscreenCanvas is a coder implementation detail).
"1600 px" selected, mono line "Uploads as 1600 × 1200 px · about 420 KB", footer "Choose
another file" / "Upload image".
2. **An SVG bypasses the step** — choose `diagram.svg` (image/svg+xml, 40 KB); assert an XHR to
`/v1/article/12/image` is created immediately and the crop step never renders.
`/v1/media/` is created immediately and the crop step never renders.
3. **Ratio presets lock the frame, Examples-driven (16:9 / 4:3 / 1:1)** — from the opened step,
select each ratio pill; assert the frame's reported rect and the mono line's output size match
the feature's Examples table.
+70 -128
View File
@@ -1,148 +1,90 @@
Feature: Removing an inline image from the editor
Feature: Removing an inline image from the article
As a verified RenewCMS owner writing an article
I want to remove an image I've already inserted into the article body,
either through an explicit delete button or by pressing Backspace/Delete
on the selected image, directly from the editor
So that a mistaken or unwanted image doesn't have to stay in the article
forever, and removing it behaves like every other destructive action in
RenewCMS: it asks for confirmation first, however the user starts it
I want to take an image out of the article body, from a button or with Backspace or Delete
So that an unwanted image leaves the article without deleting the file from my library
Background:
Given docs/design/ has no board or state for removing an inline image;
this feature was added at the user's explicit request ahead of design
coverage, reusing the editor's existing visual language rather than
inventing new components: the trash icon and error color already used
by the article "Delete" button, and the ConfirmDialog component
already used by "Delete this article?" (docs/design/screens/editor.md's
"Delete: confirm" state)
And this slice changes ArticleEditor's exposed insertImage(src, alt) to
insertImage(src, alt, imageId), stamping the inserted <img> node with
the image's numeric id as a data-image-id attribute, persisted in the
saved article body HTML; EditorView passes the id it now receives from
a successful upload (features/remove_article_image.feature's change to
POST .../image's 201 body) through to insertImage
And an image already present in an article's body from before this slice
has no data-image-id attribute and so shows no delete affordance and
is not intercepted by Backspace/Delete, until it is removed and
re-added
And selecting an image node that does carry a data-image-id attribute
shows a small delete button anchored to the image's top-right corner,
icon i-lucide-trash-2, color error, matching the article Delete
button's icon and color; deselecting the image (clicking elsewhere)
Given this slice is SPA only for this file; the Go alias deletion is in
features/remove_legacy_article_image_alias.feature
And docs/design/screens/media-picker.md now covers this flow ("Remove image: confirm" and
"Removed"); it supersedes the earlier version of this feature, which had no design
coverage and used a red confirm that deleted through DELETE /v1/article/{id}/image/{imageId}
And removing an image is a body edit only: no API call of any kind is sent, before, during
or after the confirm, and the library and every other article are untouched
And ArticleEditor keeps its exposed insertImage(src, alt, imageId) and removeImage(imageId),
its "request-remove-image" event with the image id, and its Backspace and Delete handling,
which still intercepts the keys before Tiptap deletes the node
And an image already in a body from before data-image-id existed has no id, shows no
"Remove image" button and is not intercepted by Backspace or Delete (unchanged)
And selecting an image node that carries data-image-id shows a button "Remove image" at the
image's top-right corner (8 px from the top and right): trash-2 icon at 14 px, neutral,
28 px high, 12 px medium text, a 1 px accented border and the page background; deselecting
hides it
And there are two equivalent triggers for the same removal flow:
activating that delete button, and pressing Backspace or Delete while
the image node is selected (a Tiptap NodeSelection on the image).
Tiptap's native node deletion on Backspace/Delete is intercepted
before it reaches the document (e.g. via a keyboard-shortcut/keydown
handler registered on the Image node or at the editor level, checked
first while an image node is selected) so the key press never removes
the node directly; instead it triggers exactly the same flow as the
button
And either trigger emits ArticleEditor's new "request-remove-image"
event with the node's numeric image id; ArticleEditor does not call
the API or show any dialog itself, mirroring how "request-image"
already only opens ImageUploader from EditorView
And EditorView reacts to "request-remove-image" by opening the existing
ConfirmDialog component with title "Remove this image?", description
"This image will be removed from the article. This can't be undone.",
confirmLabel "Remove image", cancelLabel "Cancel", pendingLabel
"Removing…", tone "danger" — the same component and tone already used
for "Delete this article?"
And ConfirmDialog's action calls DELETE /v1/article/{articleId}/image/
{imageId} through useApi (not the raw XMLHttpRequest used for upload,
since there is no progress to observe)
And on success (204) the action removes the image node from
ArticleEditor's content via a new exposed removeImage(imageId) method,
which moves useArticleDraft's state to "dirty" the same way any other
body edit does; ConfirmDialog then closes and a toast appears: color
success, icon i-lucide-circle-check, title "Image removed",
description "Save to keep this change."
And on any failure (404 for either the article or the image, 500, or a
network failure) the action throws, so ConfirmDialog shows its own
inline error "Couldn't remove the image, so it's still there. Try
again in a moment." and stays open; the image node is not removed and
the draft's dirty state is unchanged
And a 401 is handled entirely by useApi's global handling
(features/api_client_global_error_handling.feature) and is out of
scope for new scenarios here
And once the confirm dialog is open, its cancel/confirm/failure
behaviour (covered below) is identical regardless of which trigger
opened it, so those scenarios are written once, against the button
trigger, and are not duplicated per trigger
And EditorView reacts to "request-remove-image" by opening ConfirmDialog with tone "neutral",
title "Remove this image from the article?", description "It's removed from this article
only. The file stays in your library and in any other article that uses it.", confirmLabel
"Remove image" and cancelLabel "Cancel"; the confirm button is primary, not red
And the body of the confirm is a bordered row with a 56 × 42 px thumbnail of the image, its
filename, and "Stays in the library · {size}", for example "north-wall.jpg" and "Stays in
the library · 3.9 MB"
And the filename and size come from the images the SPA has seen this session, kept by image
id: those inserted from either tab and those loaded in the Library list
And confirming runs removeImage(imageId), moves useArticleDraft to "dirty", closes the
dialog and shows the toast; the action cannot fail, so there is no inline error
And a thumbnail uses the image's src from the node
Scenario: Selecting an inserted image reveals its delete button
Given the user is on /article/12/edit with an image in the body carrying
data-image-id "10"
Scenario: Selecting an inserted image reveals "Remove image"
Given the user is on /article/12/edit with an image in the body carrying data-image-id "1"
When the user clicks that image
Then a delete button (trash icon, error color) appears at the image's
top-right corner
Then a button "Remove image" with a trash-2 icon shows at the image's top-right corner
When the user clicks elsewhere in the editor
Then the delete button is no longer shown
Then the button is no longer shown
Scenario Outline: Either trigger opens the confirm dialog and sends no request yet, without letting Tiptap delete the node itself
Given the image with data-image-id "10" is selected on /article/12/edit
Scenario: An image without data-image-id has no button
Given the body has an image with no data-image-id
When the user clicks it
Then no "Remove image" button shows and Backspace does nothing special to it
Scenario Outline: Either trigger opens the neutral confirm and sends nothing
Given the image with data-image-id "1" ("north-wall.jpg", 3.9 MB, inserted this session) is selected on /article/12/edit
When the user <triggers-removal>
Then a dialog titled "Remove this image?" opens, with description "This
image will be removed from the article. This can't be undone.",
cancel "Cancel" and confirm "Remove image"
And no request has been sent to DELETE /v1/article/12/image/10
And the image node is still present in the editor's content (Tiptap's
native node deletion did not run)
Then a dialog titled "Remove this image from the article?" opens, with the description "It's removed from this article only. The file stays in your library and in any other article that uses it."
And its body shows the thumbnail, "north-wall.jpg" and "Stays in the library · 3.9 MB"
And its buttons read "Cancel" and a primary "Remove image"
And no request has been sent
And the image node is still in the editor's content (Tiptap's native deletion did not run)
Examples:
| triggers-removal |
| activates the image's delete button |
| presses Backspace with the image node selected |
| triggers-removal |
| activates the "Remove image" button |
| presses Backspace with the image node selected |
| presses Delete with the image node selected |
Scenario: Cancelling the confirm dialog changes nothing
Given the confirm dialog is open for image id 10 on /article/12/edit
Scenario: The confirm for an image the session has not seen
Given the image with data-image-id "7" was in the saved body and is not in the session's record
When the user activates "Remove image"
Then the body row shows the thumbnail and "Stays in the library" with no filename and no size
Scenario: Cancelling changes nothing
Given the confirm is open for image id 1
When the user activates "Cancel"
Then the dialog closes
And no request has been sent to DELETE /v1/article/12/image/10
And the image is still present in the editor's content
And the draft's dirty state is unchanged
Then the dialog closes, no request has been sent, the image is still in the content, and the draft's dirty state is unchanged
Scenario: Confirming removes the image, marks the draft dirty, and shows a success toast
Given the confirm dialog is open for image id 10 on /article/12/edit,
the draft in the Saved state
Scenario: Confirming removes the image from the body only
Given the confirm is open for image id 1 on /article/12/edit, the draft in the Saved state
When the user activates "Remove image"
Then DELETE /v1/article/12/image/10 is sent
When that request resolves 204
Then the dialog closes
And the image is removed from the editor's content
And SaveStatus reads "Unsaved changes" and the Save button becomes
enabled
And a toast appears: color success, icon i-lucide-circle-check, title
"Image removed", description "Save to keep this change."
Then the dialog closes and the image leaves the editor's content
And SaveStatus reads "Unsaved changes" and Save is enabled
And a toast appears: color success, icon i-lucide-circle-check, title "Image removed", description "It's still in your library."
And no request of any kind has been sent
Scenario Outline: A failed removal keeps the image and shows the dialog's inline error
Given the confirm dialog is open for image id 10 on /article/12/edit
Scenario: A removal started by Backspace behaves like one started by the button
Given the confirm is open for image id 1, opened by Backspace, the draft in the Saved state
When the user activates "Remove image"
And DELETE /v1/article/12/image/10 <failure>
Then the dialog stays open, showing the inline error "Couldn't remove
the image, so it's still there. Try again in a moment."
And the image is still present in the editor's content
And the draft's dirty state is unchanged
Then the outcome is the same as above
Examples:
| failure |
| resolves with a 404 response |
| resolves with a 500 response |
| never reaches the server |
Scenario: Confirming a removal started by Backspace/Delete behaves exactly like confirming one started by the button
Given the confirm dialog is open for image id 10 on /article/12/edit,
opened by pressing Backspace with the image node selected, the draft
in the Saved state
When the user activates "Remove image"
Then DELETE /v1/article/12/image/10 is sent
When that request resolves 204
Then the dialog closes
And the image is removed from the editor's content
And SaveStatus reads "Unsaved changes" and the Save button becomes
enabled
And a toast appears: color success, icon i-lucide-circle-check, title
"Image removed", description "Save to keep this change."
Scenario: The removed image can be picked again
Given image id 1 was removed from the body this session
When the user opens the Library tab
Then "north-wall.jpg" no longer carries "In this article"
+15 -78
View File
@@ -1,88 +1,25 @@
# QA: Removing an inline image from the editor
# QA: Removing an inline image from the article
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount EditorPage inside DashboardShell at `/article/12/edit` with the
editor_core.qa.md fixture article (id 12, "How we price a bathroom
renovation", is_online false), GET already resolved 200, and a body
containing `<img src="/static/uploadedImages/before.png" data-image-id="10"
alt="">`. Mock `useApi`'s `request` for `DELETE /v1/article/12/image/10`
(fetch-based, not XHR — reuse whatever mock
`api_client_global_error_handling.qa.md` already sets up for `request`).
Mount EditorPage at `/article/12/edit` with the `editor_core.qa.md` fixture article and a body of `<img src="/static/uploadedImages/1.jpg" data-image-id="1" alt="">`. Seed the session's image record with id 1 ("north-wall.jpg", 3.9 MB). Spy on `request` and on `XMLHttpRequest` and assert they are never called by any step below.
1. **Selecting the image reveals the delete button** — click the image
node; assert a delete button (trash icon, error color) renders anchored
to it; click elsewhere in the editor; assert the button is gone.
2. **Activating the delete button opens the confirm dialog** — select the
image, activate its delete button; assert a dialog titled "Remove this
image?", description "This image will be removed from the article. This
can't be undone.", cancel "Cancel", confirm "Remove image"; assert no
request was sent yet; assert the image node is still present (Tiptap's
native deletion did not run).
3. **Backspace on the selected image opens the same confirm dialog instead
of deleting the node** — select the image node (NodeSelection), fire a
`keydown` for `Backspace`; assert the same dialog as step 2 opens;
assert no request was sent yet; assert the image node is still present
in the editor's document (i.e. the keydown was intercepted before
Tiptap's default node-deletion ran, not just undone afterwards).
4. **Delete on the selected image opens the same confirm dialog instead of
deleting the node** — same as step 3 but firing `keydown` for `Delete`.
5. **Cancelling changes nothing** — from the open dialog (opened via the
button), click "Cancel"; assert the dialog closes, no request was sent,
the image node is still in the editor's content, and SaveStatus/dirty
state is unchanged.
6. **Confirming removes the image, marks dirty, and toasts (button
trigger)** — from the open dialog (Saved state), click "Remove image";
assert a DELETE request to `/v1/article/12/image/10` was made; resolve
it 204; assert the dialog closes, the image node is gone from the
editor's content, SaveStatus reads "Unsaved changes" and Save is
enabled, and a toast appears with color success, icon
`i-lucide-circle-check`, title "Image removed", description "Save to
keep this change."
7. **Confirming removes the image the same way when opened via
Backspace/Delete** — repeat step 6's assertions, but open the dialog via
the Backspace keydown from step 3 instead of the button; assert the
outcome (request, node removal, dirty state, toast) is identical.
8. **A failed removal keeps the image and shows the inline error,
Examples-driven** — for a 404 response, a 500 response, and a rejected/
network-failure request: from the open dialog (button trigger), click
"Remove image"; resolve/reject accordingly; assert the dialog stays open
showing the inline error "Couldn't remove the image, so it's still
there. Try again in a moment."; assert the image node is still present
and the draft's dirty state is unchanged.
1. **Selecting reveals the button** - click the image; assert a button named "Remove image" with a trash-2 icon renders at the corner; click elsewhere; assert it is gone.
2. **No id, no button** - an image without `data-image-id`: no button, and a Backspace keydown does not open the dialog.
3. **Triggers, Examples-driven** - button click, Backspace keydown and Delete keydown each open the dialog with the exact title, description, "Cancel" and a primary (not error) "Remove image"; assert the body shows the thumbnail, "north-wall.jpg" and "Stays in the library · 3.9 MB"; assert no request; assert the node is still in the document.
4. **Unseen image** - id 7 not in the record; assert the row shows the thumbnail and "Stays in the library" only.
5. **Cancel changes nothing.**
6. **Confirm** - click "Remove image"; assert the dialog closes, the node is gone, SaveStatus "Unsaved changes", Save enabled, the toast (color success, icon `i-lucide-circle-check`, "Image removed" / "It's still in your library."), and zero requests.
7. **Backspace-opened confirm** gives the same outcome as 6.
8. **Removed image loses its tag** - open the Library after step 6; assert "north-wall.jpg" has no "In this article" tag.
## Visual
## Visual: `task visual:check -- media-picker`
No `docs/design/` board or state exists for this trigger (see the feature's
Background). Per the user's explicit direction this slice ships the SPA
behaviour anyway, but `task visual:check -- editor` has nothing to compare
it against, so no visual capture is added for it — this is an intentional
gap, `skip: no-design-coverage` (distinct from `skip: clock-sensitive`), not
a QA oversight. If a board for this state is added to `docs/design/` later,
it needs its own follow-up slice to add the visual check.
The states "Remove image: confirm" and "Removed" are in the `media-picker` group; the setups are in `editor_media_picker.qa.md`. The sample data is north-wall.jpg, image id 1, 3.9 MB. The "Remove image" button is visible in "Remove image: confirm" and "Inserted", so the setup must select the image node. The confirm's confirm button must not be error-coloured.
- [ ] Keyboard/focus: once a delete button is shown, it is reachable by Tab
and activates on Enter/Space; pressing Backspace/Delete with the
image node selected but focus elsewhere in the page does not trigger
removal (only an actual Tiptap NodeSelection on the image should);
ConfirmDialog's existing focus trap and cancel-button-default-focus
behaviour (already covered by the article-delete flow) applies
unchanged regardless of which trigger opened it.
- [ ] Keyboard and focus: the button is reachable by Tab and activates with Enter or Space. Backspace or Delete with focus outside the editor does nothing. ConfirmDialog's focus trap and default focus on "Cancel" are unchanged.
## Manual verification
- **Real DELETE round trip against a running server** — Vitest only mocks
`useApi`'s request; confirm a real removal (204/404/500) against
`task dev` with a live SQLite file and the real
`DELETE /v1/article/{id}/image/{imageId}` handler, since
`features/remove_article_image.feature`'s httptest suite covers the
handler in isolation but never through this SPA flow.
- **Real keyboard behaviour** — confirm in a real browser that selecting an
image (click) and pressing Backspace/Delete opens the confirm dialog
rather than deleting the node, including with an IME or on a platform
where Backspace/Delete semantics differ, since jsdom's `keydown`
simulation doesn't exercise Tiptap's real ProseMirror keymap precedence.
- **Design review of the delete-button/ConfirmDialog approach** — since
there is no board for this state, once a board is added, re-verify this
implementation still matches it (or update the implementation to match);
until then this is a best-effort, design-team-unreviewed UI.
- **Real keyboard behaviour** in a real browser, including an IME: Backspace or Delete on a selected image opens the confirm instead of deleting the node (jsdom doesn't exercise ProseMirror's keymap precedence).
- **Save after removal** against `task dev`: remove an image, save, reload; the image is gone from the article and still listed in `/media`.
+171 -116
View File
@@ -1,136 +1,191 @@
Feature: Wiring the editor's image upload flow
Feature: The editor's Upload tab uploads to the library
As a verified RenewCMS owner writing an article
I want the editor's image button (and dropping a file onto the editor) to open an upload
modal that validates the file, shows progress, and inserts the result at my cursor
I want the Upload tab of the "Add an image" dialog to check my file, spot duplicates,
upload to my library and insert the result at my cursor
So that I can add images to an article's body without leaving the editor, with clear
feedback when a file is rejected or the upload fails
Background:
Given EditorView.vue listens for ArticleEditor's "request-image" event and, on it, opens
ImageUploader (a UModal titled "Add an image") with the current article's id
And dropping a file directly onto the editor surface also opens ImageUploader, with that
file already provided to it instead of an empty idle state
And ImageUploader validates a chosen or dropped file in the browser, checking both its MIME
type and its file extension against png/jpeg/webp/svg, and that its size is at most
10 × 1024 × 1024 bytes, before sending anything
And a file that passes validation is uploaded via a new useImageUpload composable, using
XMLHttpRequest (not useApi) so upload progress is observable, as multipart/form-data with
the file under the field name "file", to POST /v1/article/{articleId}/image, per
features/upload_article_image.feature's already-implemented and already-mounted contract
And a 201 response's {"location": "..."} is inserted into ArticleEditor's content at the
current cursor position via an exposed insertImage(src, alt) method, with alt ""; this
moves useArticleDraft's state to "dirty" the same way any other body edit does
And closing the modal while an upload is in flight aborts the XMLHttpRequest
And a 401 response is handled entirely by useApi's global handling
(features/api_client_global_error_handling.feature) and is out of scope for new scenarios
here; the same is true of a 404 (the article was deleted elsewhere), which the editor's
existing not-found handling already covers (features/editor_core.feature)
Given this slice is SPA only for this file: the API from the media library slice is used
unchanged
And EditorView listens for ArticleEditor's "request-image" event and opens ImageUploader
(a UModal titled "Add an image") with the picker tabs, whose Library tab is specified in
features/editor_media_picker.feature
And dropping a file onto the editor surface opens ImageUploader on the Upload tab with that
file already provided, and no list request is sent until the Library tab is opened
And this file supersedes these statements of the earlier version: the upload goes to
POST /v1/media/ and no longer to POST /v1/article/{articleId}/image; the 201 body is the
library item { id, filename, url, size, content_type, width, height, sha256, uploaded_by,
created_at, used_in, duplicate }; there is no article id in any upload request and no 404
for a deleted article on upload
And ImageUploader validates a chosen or dropped file in the browser with validateImageFile
from web/src/lib/imageUpload.ts, size first (10485760 bytes allowed, one byte more is too
large), then MIME type and extension must both be allowed (png, jpeg, webp, svg), before
hashing or sending anything
And a file that passes validation is hashed with hashFile from web/src/lib/fileHash.ts (the
lib shared with MediaPage), and GET /v1/media/?hash=<sha256 hex> is sent through useApi;
when hashFile resolves null, or the lookup fails with 500 or a network error, the check
is skipped and the flow continues as if nothing matched
And a file with no match continues exactly as in features/editor_image_crop_resize.feature:
a JPEG, PNG or WEBP opens the crop and resize step, an SVG is uploaded immediately
And the upload is sent with XMLHttpRequest (not useApi) so progress is observable, as
multipart/form-data with the file under the field name "file" to POST /v1/media/, with the
jwt cookie
And a 201 inserts the item's url at the cursor through insertImage(item.url, "", item.id),
so the node carries data-image-id, and useArticleDraft moves to "dirty"
And closing the dialog while an upload is in flight aborts the XMLHttpRequest
And the tabs show only on the choose step (idle, the duplicate card and the Library tab);
the crop, uploading, rejected and failed steps keep the earlier look without tabs
And the footer on the Upload tab holds only the step's own actions ("Cancel" in idle);
there is no "Insert image" button on it
Scenario: Opening the uploader from the toolbar's image button shows the idle state
Given the user is on /article/12/edit with the editor focused
When the user activates the toolbar's "Image" button
Then ImageUploader opens titled "Add an image"
And it shows "Drop an image here", a "Choose file" action, and the help text
"PNG, JPEG, WEBP, SVG · max 10 MB"
And its cancel action reads "Cancel"
And no request has been sent
Rule: Opening
Scenario: Dragging a file over the editor surface shows the dragging-over state
Given ImageUploader is open in the idle state
When the user drags a file over the drop zone
Then the drop zone reads "Drop to upload"
Scenario: The toolbar's image button opens on Upload when the library is empty
Given the user is on /article/12/edit with the editor focused
And GET /v1/media/?limit=24 resolves 200 with no item
When the user activates the toolbar's "Image" button
Then ImageUploader opens titled "Add an image" with the tabs "Upload" (icon upload) and "Library" (icon images)
And the Upload tab is selected and shows the image-plus icon, "Drop an image here, or choose a file" and "PNG, JPEG, WEBP, SVG · max 10 MB"
And its footer reads "Cancel" only
And no POST has been sent
Scenario: Dropping a valid file onto the editor opens the uploader already uploading it
Given the user is on /article/12/edit with the editor focused and ImageUploader is not open
When the user drops a file named "before.png", content type "image/png", size 3.9 MB, onto
the editor surface
Then ImageUploader opens titled "Add an image"
And POST /v1/article/12/image is sent with that file under the field name "file"
And the uploading state is shown, not the idle state
Scenario: Dropping a file opens Upload with the file
Given the user is on /article/12/edit and ImageUploader is not open
When the user drops a file named "before.png", content type "image/png", size 3.9 MB, onto the editor surface
Then ImageUploader opens on the Upload tab
And GET /v1/media/?hash=<sha256 of the file> is sent, and no request to GET /v1/media/?limit=24 is sent
And when the lookup resolves 200 with no item the crop and resize step shows for "before.png"
And no POST /v1/media/ has been sent
Scenario Outline: Choosing a file the browser can reject never sends a request
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "<filename>", content type "<content_type>", size <size>
Then no request is sent to POST /v1/article/12/image
And the rejected state shows the title "\"<filename>\" can't be used"
And its text reads "<description>"
And its confirm action reads "Choose another file" and its cancel action reads "Cancel"
Scenario: Dragging a file over the drop zone shows the dragging-over state
Given ImageUploader is open on the Upload tab in the idle state
When the user drags a file over the drop zone
Then the drop zone reads "Drop to upload"
Examples:
| filename | content_type | size | description |
| plan.pdf | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
| disguised.png | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
| north-wall.tif | image/png | 14.2 MB | north-wall.tif is 14.2 MB. Images can be up to 10 MB. |
Rule: Browser checks, nothing is sent
Scenario: "Choose another file" from the rejected state returns to idle
Given ImageUploader is in the rejected state for a file named "plan.pdf"
When the user activates "Choose another file"
Then the idle state is shown again, with the file picker available
Scenario Outline: Choosing a file the browser can reject sends nothing
Given ImageUploader is open in the idle state
When the user chooses a file named "<filename>", content type "<content_type>", size <size>
Then no hash lookup and no POST /v1/media/ is sent
And the rejected state shows the title "\"<filename>\" can't be used"
And its text reads "<description>"
And its confirm action reads "Choose another file" and its cancel action reads "Cancel"
Scenario: A valid upload shows progress and its cancel action reads "Cancel upload"
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "north-wall.jpg", content type "image/jpeg", size 3.9 MB
Then POST /v1/article/12/image is sent with that file under the field name "file"
And the uploading state shows "north-wall.jpg 0%" and "Uploading 0.0 of 3.9 MB"
And its cancel action reads "Cancel upload"
When the upload reports 2.4 MB sent of 3.9 MB
Then the uploading state shows "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB"
Examples:
| filename | content_type | size | description |
| plan.pdf | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
| disguised.png | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
| north-wall.tif | image/png | 14.2 MB | north-wall.tif is 14.2 MB. Images can be up to 10 MB. |
Scenario: Closing the dialog during an upload cancels it
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12
When the user activates "Cancel upload" (or otherwise closes the modal)
Then the in-flight XMLHttpRequest is aborted
And the modal closes
And the editor's body is unchanged and remains in its previous dirty/clean state
Scenario: "Choose another file" from the rejected state returns to idle
Given ImageUploader is in the rejected state for "plan.pdf"
When the user activates "Choose another file"
Then the idle state is shown again, with the tabs
Scenario: A successful upload inserts the image at the cursor and marks the draft dirty
Given the user is on /article/12/edit in the Saved state and ImageUploader is uploading a
file named "north-wall.jpg" to article 12
When POST /v1/article/12/image resolves 201 with {"location": "/static/uploadedImages/north-wall.jpg"}
Then the uploading state briefly shows "Image added"
And the image at "/static/uploadedImages/north-wall.jpg" is inserted into the editor's
content at the cursor via insertImage
And SaveStatus reads "Unsaved changes" and the Save button becomes enabled
And a toast appears: color success, icon i-lucide-circle-check, title "Image added",
description "Save to keep it in the article."
And the modal closes 600 ms after the response resolves
Rule: Already in your library
Scenario Outline: A server-side upload failure keeps the article unchanged and offers a retry
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12
When POST /v1/article/12/image <failure>
Then the failed state shows the title "Upload failed"
And its text reads "The server didn't accept the file. Your article hasn't changed."
And its confirm action reads "Try again"
And the editor's body is unchanged, and no image is inserted
Scenario: A file whose content exists shows the duplicate card and sends nothing
Given ImageUploader is open in the idle state
And GET /v1/media/?hash=<sha256 of north-wall.jpg> resolves 200 with the image "north-wall.jpg" (id 1, 4032 × 3024, uploaded 12 Sep 2026 by Marta Alves)
When the user chooses a valid file named "north-wall.jpg" of 3.9 MB
Then the Upload tab shows a card with an info border: a 72 × 54 thumbnail of the existing image, the title "Already in your library" and the text "north-wall.jpg matches an image uploaded on 12 Sep 2026 by Marta Alves."
And the card has the buttons "Use existing" (primary) and "Upload anyway", stacked and full width below md
And no POST /v1/media/ has been sent and the crop step has not opened
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Several matches name the oldest one
Given the hash lookup resolves 200 with several images
Then the card names the oldest match
Scenario: "Try again" re-attempts the upload with the same file
Given ImageUploader is in the failed state for a file named "north-wall.jpg", article 12,
after a 500 response
When the user activates "Try again"
Then POST /v1/article/12/image is sent again with that same file under the field name "file"
And the uploading state is shown
Scenario: A match without a known uploader drops the "by" part
Given the matched image's uploaded_by is empty
Then the text reads "north-wall.jpg matches an image uploaded on 12 Sep 2026."
Scenario: A server 400 for a type the client should have caught shows the same rejected copy
Given ImageUploader is uploading a file named "notes.pdf" to article 12 (a type that
defensively passed the browser's own check, e.g. a MIME type the browser misreported)
When POST /v1/article/12/image resolves 400
Then the rejected state shows the title "\"notes.pdf\" can't be used"
And its text reads "Use a PNG, JPEG, WEBP or SVG image, up to 10 MB."
And its confirm action reads "Choose another file"
And the editor's body is unchanged, and no image is inserted
Scenario: "Use existing" goes to the alt text step for the existing image
Given the duplicate card is showing for image id 1
When the user activates "Use existing"
Then the Library tab is selected with "north-wall.jpg" selected, its panel visible with the empty "Alt text (optional)" field, and "Insert image" enabled
And no POST /v1/media/ has been sent
Scenario: A server 400 for a size the client should have caught shows the same size-named copy
Given ImageUploader is uploading a file named "north-wall.tif" to article 12 (a file that
defensively passed the browser's own size check somehow, e.g. its reported size changed
between selection and send)
When POST /v1/article/12/image resolves 400
Then the rejected state shows the title "\"north-wall.tif\" can't be used"
And its text reads "north-wall.tif is 14.2 MB. Images can be up to 10 MB."
And its confirm action reads "Choose another file"
And the editor's body is unchanged, and no image is inserted
Scenario: "Upload anyway" continues to crop and upload
Given the duplicate card is showing for "north-wall.jpg" (4032 × 3024)
When the user activates "Upload anyway"
Then the crop and resize step opens for that file
And the hash lookup is not repeated
And uploading from that step sends POST /v1/media/ and follows the normal states
Scenario: "Upload anyway" for an SVG uploads immediately
Given the duplicate card is showing for "diagram.svg"
When the user activates "Upload anyway"
Then POST /v1/media/ is sent with that file
Scenario Outline: The duplicate check is skipped when it cannot run
Given ImageUploader is open in the idle state
When the user chooses a valid file and <condition>
Then the flow continues to the crop step (or the upload for an SVG) with no card
Examples:
| condition |
| crypto.subtle is undefined (an insecure context) |
| GET /v1/media/?hash= resolves with a 500 response |
| GET /v1/media/?hash= never reaches the server |
Rule: Uploading
Scenario: A valid upload shows progress and its cancel action reads "Cancel upload"
Given the crop step was confirmed (or an SVG chosen) for "north-wall.jpg", 3.9 MB
Then POST /v1/media/ is sent with the file under the field name "file"
And the uploading state shows "north-wall.jpg 0%" and "Uploading 0.0 of 3.9 MB", and its cancel action reads "Cancel upload"
When the upload reports 2.4 MB sent of 3.9 MB
Then it shows "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB"
Scenario: Closing the dialog during an upload cancels it
Given ImageUploader is uploading "north-wall.jpg"
When the user activates "Cancel upload" (or otherwise closes the dialog)
Then the in-flight XMLHttpRequest is aborted and the dialog closes
And the editor's body and its dirty or clean state are unchanged
Scenario: A successful upload inserts the image at the cursor and marks the draft dirty
Given the user is on /article/12/edit in the Saved state and "north-wall.jpg" is uploading
When POST /v1/media/ resolves 201 with the item { id: 13, url: "/static/uploadedImages/13.jpg", filename: "north-wall.jpg" }
Then the uploading state briefly shows "Image added"
And the image "/static/uploadedImages/13.jpg" is inserted at the cursor with alt "" and data-image-id 13
And SaveStatus reads "Unsaved changes" and Save is enabled
And a toast appears: color success, icon i-lucide-circle-check, title "Image added", description "Save to keep it in the article."
And the dialog closes 600 ms after the response
Scenario Outline: A server answer for a file that passed the browser checks
Given ImageUploader is uploading "<file>" of <size>
When POST /v1/media/ resolves <status>
Then the <state> state shows the title "<title>"
And its text reads "<text>"
And its confirm action reads "<confirm>"
And no image is inserted and the editor's body is unchanged
Examples:
| file | size | status | state | title | text | confirm |
| notes.pdf | 210 KB | 415 | rejected | "notes.pdf" can't be used | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. | Choose another file |
| north-wall.tif | 14.2 MB | 413 | rejected | "north-wall.tif" can't be used | north-wall.tif is 14.2 MB. Images can be up to 10 MB. | Choose another file |
| broken.png | 3.0 MB | 400 | rejected | "broken.png" can't be used | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. | Choose another file |
| north-wall.jpg | 3.9 MB | 500 | failed | Upload failed | The server didn't accept the file. Your article hasn't changed. | Try again |
| north-wall.jpg | 3.9 MB | 429 | failed | Upload failed | The server didn't accept the file. Your article hasn't changed. | Try again |
| north-wall.jpg | 3.9 MB | none (network) | failed | Upload failed | The server didn't accept the file. Your article hasn't changed. | Try again |
Scenario: "Try again" re-attempts the upload with the same file
Given ImageUploader is in the failed state for "north-wall.jpg" after a 500
When the user activates "Try again"
Then POST /v1/media/ is sent again with that same file and no new hash lookup, and the uploading state shows
Rule: Sessions
Scenario Outline: Session errors are global
When <call> resolves <status>
Then the user is sent where useApi sends a <status> from /article/12/edit, and no local copy shows
Examples:
| call | status |
| POST /v1/media/ (XHR) | 401 |
| POST /v1/media/ (XHR) | 403 |
| GET /v1/media/?hash= | 401 |
+24 -81
View File
@@ -1,90 +1,33 @@
# QA: Wiring the editor's image upload flow
# QA: The editor's Upload tab uploads to the library
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `XMLHttpRequest` (a fake implementing `open`/`setRequestHeader`/`send`/`abort` and firing
`upload.progress`, `load`, `error`) for `POST /v1/article/{id}/image`; useApi/fetch is not
involved in this slice. Mount EditorPage inside DashboardShell at `/article/12/edit` with the
editor_core.qa.md fixture article (id 12, title "How we price a bathroom renovation", is_online
false), GET already resolved 200.
Mock `XMLHttpRequest` (a fake with `open`, `setRequestHeader`, `send`, `abort`, firing `upload.progress`, `load`, `error`) for `POST /v1/media/`. Mock useApi's `request` for `GET /v1/media/` (list and `?hash=`), reusing the mock from `api_client_global_error_handling.qa.md`. Stub `hashFile` where a hash is needed, and add one test with `crypto.subtle` deleted. Mount EditorPage inside DashboardShell at `/article/12/edit` with the `editor_core.qa.md` fixture article, GET already resolved 200. Assert URLs by parsing the query, not by string equality.
1. **Toolbar button opens the idle state** — click the toolbar's "Image" button; assert
ImageUploader opens titled "Add an image", showing "Drop an image here", "Choose file",
"PNG, JPEG, WEBP, SVG · max 10 MB", cancel "Cancel"; assert no XHR was created.
2. **Dragging over shows "Drop to upload"** — with the uploader open idle, fire a `dragover` on
the drop zone; assert the text changes to "Drop to upload".
3. **Dropping a file on the editor opens the uploader already uploading** — with the uploader
closed, dispatch a `drop` event with a file `before.png` (image/png, 3.9 MB) on the editor
surface; assert ImageUploader opens and an XHR to `/v1/article/12/image` is created with that
file, and the uploading state (not idle) is shown.
4. **Rejected — Examples-driven on type, extension mismatch, and size** — for each of `plan.pdf`
(application/pdf, 1.2 MB), `disguised.png` (application/pdf, 1.2 MB), and `north-wall.tif`
(image/png, 14.2 MB), choose the file via the `UFileUpload`; assert no XHR is created and the
rejected state shows the exact title/text/confirm/cancel copy from the feature's Examples.
5. **"Choose another file" returns to idle** — from the rejected state, click "Choose another
file"; assert the idle state re-renders.
6. **Uploading shows live progress and "Cancel upload"** — choose `north-wall.jpg` (image/jpeg,
3.9 MB); assert the XHR is created with that file under field `file`; assert cancel reads
"Cancel upload"; fire an `upload.progress` event with `loaded: 2.4 * 1024 * 1024, total: 3.9 *
1024 * 1024`; assert the text reads "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB".
7. **Closing during an upload aborts the XHR** — from the uploading state, click "Cancel
upload"; assert the fake XHR's `abort` was called, the modal closes, and the editor's body
and dirty state are unchanged from before the upload started.
8. **A 201 inserts at the cursor and marks the draft dirty** — from the uploading state, resolve
the fake XHR with status 201 and body `{"location": "/static/uploadedImages/north-wall.jpg"}`;
assert `insertImage` (or the editor's exposed method) was called with that location; assert
SaveStatus now reads "Unsaved changes" and Save is enabled; assert a toast appears with color
success, icon `i-lucide-circle-check`, title "Image added", description "Save to keep it in
the article."; advance fake timers 600 ms and assert the modal has closed.
9. **A 500 or network failure shows "Upload failed" and keeps the article unchanged, Examples-
driven** — for (a) the fake XHR firing `load` with status 500 and (b) firing `error`; assert
the failed state shows title "Upload failed", text "The server didn't accept the file. Your
article hasn't changed.", confirm "Try again"; assert no `insertImage` call and the editor's
body/dirty state is unchanged.
10. **"Try again" resends the same file** — from the failed state for `north-wall.jpg`, click
"Try again"; assert a new XHR is created for `/v1/article/12/image` with the same file, and
the uploading state is shown again.
11. **A server 400 for a type the client should have caught shows the rejected copy** — from an
uploading state for `notes.pdf`, resolve the fake XHR with status 400; assert the rejected
state shows title "\"notes.pdf\" can't be used", text "Use a PNG, JPEG, WEBP or SVG image,
up to 10 MB.", confirm "Choose another file"; assert no `insertImage` call.
12. **A server 400 for a size the client should have caught shows the size-named rejected copy**
— from an uploading state for `north-wall.tif`, resolve the fake XHR with status 400; assert
the rejected state shows title "\"north-wall.tif\" can't be used", text "north-wall.tif is
14.2 MB. Images can be up to 10 MB.", confirm "Choose another file"; assert no `insertImage`
call.
1. **Toolbar button, empty library** - `GET /v1/media/?limit=24` resolves `{items: [], next_cursor: null, counts: {all: 0, unused: 0, duplicates: 0}}`; click "Image"; assert the dialog "Add an image", both tabs, the Upload tab selected, the drop zone copy, footer "Cancel" only, no XHR.
2. **Drop opens Upload with the file** - dispatch `drop` with `before.png` (image/png, 3.9 MB); assert the Upload tab, a `?hash=` request, no `?limit=24` list request, then the crop step after an empty lookup, and no XHR.
3. **Dragging over shows "Drop to upload"**.
4. **Rejected, Examples-driven** - `plan.pdf`, `disguised.png`, `north-wall.tif`; assert no `?hash=` request and no XHR, plus the exact title, text, confirm and cancel.
5. **"Choose another file" returns to idle**, with the tabs back.
6. **Duplicate card** - the hash lookup resolves one item (id 1, "north-wall.jpg", `created_at` 12 Sep 2026, `uploaded_by` "Marta Alves"); assert the card copy and buttons, no XHR, and no crop step.
7. **Several matches name the oldest**, and **no uploader drops the "by" part**.
8. **"Use existing"** - assert the Library tab is selected, image 1 selected, the alt field empty, "Insert image" enabled, and no XHR.
9. **"Upload anyway"** - assert the crop step opens (raster) with no second `?hash=` request; for an SVG assert the XHR is created at once.
10. **Check skipped** - Examples: `hashFile` resolves null, lookup 500, lookup rejects; assert the crop step opens with no card.
11. **Uploading and progress** - confirm the crop step; assert the XHR is `POST /v1/media/` with field `file`, cancel "Cancel upload", and `loaded: 2.4 * 1024 * 1024, total: 3.9 * 1024 * 1024` gives "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB".
12. **Closing during an upload aborts** - `abort` called, dialog closed, body and dirty state unchanged.
13. **201 inserts** - resolve the XHR with the item (id 13, url `/static/uploadedImages/13.jpg`); assert `insertImage` was called with (url, "", 13), SaveStatus "Unsaved changes", Save enabled, the toast (color success, icon `i-lucide-circle-check`, "Image added" / "Save to keep it in the article."), and after 600 ms of fake timers the dialog closes.
14. **Server answers, Examples-driven** - 415, 413, 400 give the rejected copy; 500, 429 and a network `error` give "Upload failed"; assert no `insertImage` call and the body unchanged.
15. **"Try again" resends the same file** with no new `?hash=` request.
16. **Sessions** - Examples from the feature; assert the same redirect `useApi` performs, with no local copy.
## Visual (manual verification — desktop and mobile, light and dark)
## Visual
Reference: `docs/design/screens/editor.md` states "Upload: choose", "Upload: in progress",
"Upload: rejected", "Upload: inserted", and `docs/design/source/RenewCMS Screens Back-
office.dc.html`. Run `task visual:check -- editor` for each state below; add each state's mock
setup to `web/visual/states.ts` first, reusing editor_core.qa.md's fixture article (id 12, title
"How we price a bathroom renovation", draft, "Today, 11:02", 3 images) plus the sample file data
from the board: filename "north-wall.jpg", size 3.9 MB, progress 62% / 2.4 of 3.9 MB uploaded.
- [ ] Upload: choose — modal "Add an image", idle state, cancel "Cancel".
- [ ] Upload: in progress — modal "Add an image", uploading "north-wall.jpg 62%" / "Uploading
2.4 of 3.9 MB", cancel "Cancel upload".
- [ ] Upload: rejected — modal "Add an image", rejected state for a sample rejected file per the
board's script, cancel "Cancel", confirm "Choose another file".
- [ ] Upload: inserted — toast "Image added" / "Save to keep it in the article.", editor status
"draft", save "dirty".
- [ ] Mobile (all of the above at 390×844): same modal layout constraints as other editor
modals (leading arrow-left button, trailing ellipsis in the header behind it).
- [ ] Keyboard/focus: the drop zone is reachable by Tab and Enter opens the file picker; focus
moves into the modal on open and is trapped there; Esc during "Upload: in progress"
behaves like "Cancel upload" (aborts, closes); the progress bar's `role="progressbar"` /
`aria-valuenow` updates are announced without spamming on every progress tick.
- [ ] Motion: modal open/close follows the standard 240 ms timing; the success state's 600 ms
auto-close is not visually checked as a capture (it's a timing behavior, not a static
state) — cover only via the Vitest fake-timer assertion above.
The Upload tab states are drawn in the `media-picker` group; see `editor_media_picker.qa.md`. The editor-group state 'Upload: choose' awaits the user's editor board update: the user refreshes the editor board themselves, and until then `task visual:check -- editor` is expected to fail on that state. The states "Upload: in progress" and "Upload: rejected" and the crop and resize states are unchanged. `web/visual/states.ts` line 2309 mocks `POST /v1/article/12/image` and must mock `POST /v1/media/` with the item shape, and every editor upload state needs a `GET /v1/media/?limit=24` mock.
## Manual verification
- **Real POST round trip against a running server** — Vitest only mocks `XMLHttpRequest`;
confirm a real upload (200/400/401/404/500) against `task dev` with a live SQLite file and the
real `POST /v1/article/{id}/image` handler, since `features/upload_article_image.feature`'s
httptest suite covers the handler in isolation but never through this SPA flow.
- **Real drag-and-drop from the OS file picker** — jsdom's `drop` event is synthetic; confirm a
real OS-level drag of an image file onto the editor in a real browser.
- **Real round trip against `task dev`** with a live SQLite file: a real upload through the SPA (201, 413, 415, 400, 401), then the image shows in `/media`.
- **Real OS drag and drop** of an image onto the editor in a real browser.
- **Real `crypto.subtle`** in a secure context (localhost or HTTPS), including the duplicate card for a file already in the library, and the skipped check on plain HTTP.
- **Keyboard and focus**: the drop zone is reachable by Tab and opens the file chooser with Enter or Space. Focus moves into the dialog and is trapped. Esc during "Upload: in progress" aborts and closes. Tabs are reachable and arrow keys move between them.
+221
View File
@@ -0,0 +1,221 @@
Feature: The editor's Library tab, picking an image from the media library
As a verified RenewCMS owner writing an article
I want to browse my library from the "Add an image" dialog and insert an existing image
with alt text
So that I can reuse an image without uploading it again
Background:
Given this slice is SPA only for this feature: the API from the media library slice is used
unchanged, and the Go change to remove the legacy alias is specified in
features/remove_legacy_article_image_alias.feature
And the screen is docs/design/screens/media-picker.md, drawn by docs/design/source/MediaFrame.dc.html,
inside ImageUploader (UModal "Add an image") on /article/:id/edit
And the dialog has a title "Add an image", an x to close, and two tabs under the title,
"Upload" (icon upload) and "Library" (icon images), 36 px high on desktop and 44 px on
mobile with a 2 px primary underline on the selected one; it is 640 px wide on desktop and
a bottom sheet on mobile
And the footer reads "Cancel", and on the Library tab also a primary "Insert image" that is
disabled until an image is selected
And the Library tab calls GET /v1/media/ through useApi with limit=24, with q only while a
search is set and cursor only when loading more (no filter and no sort parameter), and the
Upload tab is specified in features/editor_image_upload.feature
And the response is { items, next_cursor, counts }, newest first; sizes read "8 KB" and
"3.9 MB", dimensions read "2400 × 1600" (an SVG reads "Vector"), dates are absolute
And the 12-image fixture, ids 1 to 12, is the one in features/media_browse.feature, and the
picker mocks use next_cursor "c24" and counts { all: 48, unused: 2, duplicates: 2 }
And an image is "in this article" when an image node with its id as data-image-id is in
the editor's current content, unsaved insertions included, when the dialog opens;
in the examples below images 1 (north-wall.jpg) and 4 (logo-renew.svg) are in the article
And the search field reads "Search by filename" (32 px high on desktop, 44 px with 16 px
text on mobile), with a search icon and, once it has text, an x that clears it
And the grid has 4 columns on desktop and 3 on mobile with a 12 px gap (8 px between
columns on mobile), square tiles with a 1 px border, and the filename under each tile in
12 px toned text with an ellipsis; it scrolls inside the dialog, 300 px high with nothing
selected, 190 px on desktop and 120 px on mobile with one selected
And every tile is one button whose accessible name reads like the Media page's, for example
"north-wall.jpg, 3.9 MB, used in 2 articles" or "window-frame.jpg, 1.8 MB, unused"
And skeleton tiles show only after 150 ms: 8 on desktop and 9 on mobile for a first load
or a search, one row (4 or 3) at the end for "Load more"
And a 401 or 403 on any of these calls is handled globally by useApi, with no local copy
Rule: Which tab opens
Scenario: The library has images, so Library opens
Given GET /v1/media/?limit=24 resolves 200 with the 12-image fixture
When the user activates the toolbar's "Image" button
Then the Library tab is selected and the footer reads "Cancel" and a disabled "Insert image"
And nothing is drawn in the grid for the first 150 ms, then 8 skeleton tiles show until the response arrives
Scenario: The library is empty, so Upload opens
Given GET /v1/media/?limit=24 resolves 200 with no item and no search set
When the dialog opens
Then the Upload tab is selected, in its idle state
When the user selects the Library tab
Then the empty panel shows
Scenario: A dropped file opens Upload and sends no list request
When the user drops "before.png" onto the editor
Then the Upload tab is selected and no GET /v1/media/?limit=24 is sent
When the user selects the Library tab
Then GET /v1/media/?limit=24 is sent
Rule: Browsing
Scenario: Library, nothing selected
Given the first page resolved with the 12-image fixture
Then the line "Newest first · 48 images" shows under the search field
And 12 tiles show in fixture order, 4 per row on desktop and 3 on mobile, each with its filename
And the tiles of "north-wall.jpg" and "logo-renew.svg" carry a sand "In this article" tag (bottom-left, 18 px high, 11 px text)
And no other tile carries it
And "Load more" shows below the grid, and "Insert image" is disabled
Scenario: A search is active, so the count is not shown
Given the user typed "wall" and the response resolved with 2 items
Then the line reads "Newest first"
Scenario: Load more is hidden on the last page
Given the response has next_cursor null
Then "Load more" is not shown
Scenario Outline: A tile's accessible name
Given the first page resolved with the 12-image fixture
Then the tile for "<filename>" is named "<name>"
Examples:
| filename | name |
| north-wall.jpg | north-wall.jpg, 3.9 MB, used in 2 articles |
| kitchen-before.webp | kitchen-before.webp, 1.1 MB, used in 1 article |
| window-frame.jpg | window-frame.jpg, 1.8 MB, unused |
Rule: Selection
Scenario Outline: A tile is selected by pointer or keyboard
Given the first page resolved with the 12-image fixture
When the user <action> on the tile of "kitchen-after.webp"
Then that tile has a 2 px primary outline (2 px offset) and a 20 px primary check at its top-right
And "Insert image" is enabled
Examples:
| action |
| clicks |
| presses Enter |
| presses Space |
Scenario: The selection panel
Given "kitchen-after.webp" is selected
Then the grid shrinks and a panel shows below it, under a 1 px top border
And the panel has a 56 px thumbnail, the filename "kitchen-after.webp", "2400 × 1600 · 1.2 MB", and a field "Alt text (optional)"
And the field's placeholder reads "e.g. North wall of the kitchen before plastering"
And its help reads "Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration."
And "Load more" is hidden while an image is selected
Scenario: Selection is single
Given "kitchen-after.webp" is selected and the alt text reads "A kitchen"
When the user selects "window-frame.jpg"
Then only "window-frame.jpg" is selected and the alt text field is empty
Scenario: Activating the selected tile again keeps it selected
Given "kitchen-after.webp" is selected
When the user activates its tile
Then it stays selected
Scenario: An SVG's panel reads "Vector"
When the user selects "logo-renew.svg"
Then the panel reads "Vector · 8 KB"
Scenario: An image already in the article can be selected and inserted again
When the user selects "north-wall.jpg", which carries "In this article"
Then "Insert image" is enabled
Rule: Insert
Scenario: Insert with alt text
Given the user is on /article/12/edit in the Saved state with "kitchen-after.webp" (id 2, url "/static/uploadedImages/2.webp") selected
And the alt text reads "A kitchen with new oak worktops and white tiles"
When the user activates "Insert image"
Then insertImage is called with "/static/uploadedImages/2.webp", "A kitchen with new oak worktops and white tiles" and image id 2
And the image is in the editor's content at the cursor, with data-image-id 2
And SaveStatus reads "Unsaved changes" and Save is enabled
And a toast appears: color success, icon i-lucide-circle-check, title "Image added", description "Save to keep it in the article."
And the dialog closes at once
And no POST, PATCH or DELETE has been sent
Scenario: Empty alt text inserts a decorative image
Given "kitchen-after.webp" is selected and the alt text is empty
When the user activates "Insert image"
Then insertImage is called with alt ""
Scenario: Alt text lives only in the article
Given "kitchen-after.webp" is selected with alt text "A kitchen"
When the user activates "Insert image"
Then no request carries the alt text and the library item is unchanged
Scenario Outline: Leaving without inserting changes nothing
Given "kitchen-after.webp" is selected with alt text "A kitchen"
When the user <action>
Then the dialog closes, the editor's content and dirty state are unchanged, and no toast shows
Examples:
| action |
| activates "Cancel" |
| activates the x |
| presses Escape |
Rule: Search
Scenario: Typing searches after a debounce
Given the Library tab shows the 12-image fixture
When the user types "wall"
Then after a 120 ms debounce GET /v1/media/ is sent with q=wall and limit=24 and no cursor
And skeleton tiles show after 150 ms, and any selection is cleared
Scenario: The x in the field clears the search
Given the search field holds "wall" with results shown
When the user activates the x in the field
Then the field is empty and GET /v1/media/ is sent with limit=24 and no q
Scenario: No results
Given GET /v1/media/?q=wall&limit=24 resolves 200 with no item
Then the search icon shows with the title `No images match "wall"`, the text "Check the spelling, or search for a shorter part of the filename." and a "Clear search" button
And the search field still shows "wall" above it, and "Insert image" is disabled
When the user activates "Clear search"
Then the field is emptied and the list is requested again with no q
Rule: Load more
Scenario: Loading more shows progress and appends
Given the first page resolved with 12 images, next_cursor "c24"
When the user activates "Load more"
Then GET /v1/media/ is sent with limit=24 and cursor=c24
And the button reads "Loading…" with a loader-circle and is disabled, and one row of skeleton tiles shows at the end
And focus stays on the button
When the request resolves 200 with 12 more images
Then the new tiles are appended after the first 12, and focus moves to the first new tile
Scenario: The cursor and the search combine
Given the search "wall" is active and the page has next_cursor "c24"
When the user activates "Load more"
Then GET /v1/media/ is sent with q=wall, limit=24 and cursor=c24
Rule: Empty and failed
Scenario: Empty library
Given the Library tab is selected and GET /v1/media/?limit=24 resolved with no item
Then the images icon shows with "Your library is empty", "Images you upload appear here, ready to use in any article." and a primary "Upload an image"
And "Insert image" is disabled
When the user activates "Upload an image"
Then the Upload tab is selected
Scenario Outline: The library fails to load
Given GET /v1/media/ <failure>
When the Library tab loads
Then an error alert shows in the dialog: icon circle-x, title "Couldn't load your library", text "Check your connection and try again.", and a "Try again" button
And "Insert image" is disabled
When the user activates "Try again"
Then the same request is sent again with a skeleton, and on 200 the tiles replace the alert
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
+54
View File
@@ -0,0 +1,54 @@
# QA: The editor's Library tab
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `request` for `GET /v1/media/` by parsed query. Use the 12-image fixture from `media_browse.feature` as `items`, with `next_cursor: "c24"` and `counts: {all: 48, unused: 2, duplicates: 2}`. Mount EditorPage at `/article/12/edit` with a body containing `<img data-image-id="1">` and `<img data-image-id="4">`. Use fake timers for the 150 ms and 120 ms steps.
1. **Opens on Library with images** - first response has items; assert Library selected and skeleton tiles only after 150 ms.
2. **Opens on Upload when empty** - empty first response; assert Upload selected; then click Library and assert the empty panel.
3. **Drop sends no list request** - drop a file; assert no `?limit=24` request until Library is clicked.
4. **Nothing selected** - assert the line, 12 tiles in order, the "In this article" tag on exactly ids 1 and 4, "Load more", "Insert image" disabled.
5. **Active search hides the count** - the line reads "Newest first".
6. **Last page** - `next_cursor: null` hides "Load more".
7. **Accessible names** - the three Examples.
8. **Selection** - click, Enter and Space each select; check, outline, "Insert image" enabled.
9. **Panel copy** - kitchen-after.webp panel, placeholder and help text exact; "Load more" hidden; SVG reads "Vector · 8 KB".
10. **Single selection resets alt**; reactivating keeps it selected.
11. **Insert** - alt "A kitchen with new oak worktops and white tiles" gives `insertImage("/static/uploadedImages/2.webp", alt, 2)`, SaveStatus "Unsaved changes", the toast, the dialog closed, no non-GET request. Empty alt gives `""`.
12. **Cancel, x and Escape** change nothing.
13. **Search** - type "wall"; at 119 ms no request, at 120 ms the request has `q=wall&limit=24` and no cursor; the selection is cleared. The field's x and "Clear search" send a request with no `q`.
14. **No results** and **Empty** copy; "Upload an image" switches to the Upload tab.
15. **Load more** - request has `cursor=c24`; button "Loading…" and disabled; skeleton row of 4 (3 below md); focus on the button, then on the first new tile after resolve; combined `q` and `cursor`.
16. **Failed to load, Examples 500 and network** - alert copy; "Try again" repeats the request; success replaces the alert.
## Visual: `task visual:check -- media-picker`
Add a setup in `web/visual/states.ts` for each of the 13 states in `docs/design/screens/media-picker.md`, from the board's script (`RenewCMS Screens Back-office.dc.html` lines 239 to 253). Every capture must be within 10 % at 1440×900 and 390×844, light and dark. The sample data to reuse:
- Library mock: the 12-image fixture, `counts.all` 48, `next_cursor` non-null; images 1 and 4 in the article. The dialog is opened on the editor "How we price a bathroom renovation".
- "Upload tab": Upload selected, idle drop zone.
- "Upload tab: already in your library": a chosen file named `north-wall.jpg`; the hash lookup returns id 1 (12 Sep 2026, Marta Alves); the thumbnail is 72 × 54.
- "Library: loading": skeleton tiles.
- "Library: nothing selected": the 12 tiles, tags on ids 1 and 4, "Load more".
- "Library: selected": id 2 (kitchen-after.webp, 2400 × 1600 · 1.2 MB), empty alt.
- "Library: alt text entered": id 2 with alt "A kitchen with new oak worktops and white tiles".
- "Library: loading more": 12 tiles plus one skeleton row, button "Loading…".
- "Library: empty": a mock with zero items. "Library: no results": search "wall" and zero items.
- "Library: failed to load": a 500.
- "Inserted": the editor with an image (id 1 as the board draws it) and the toast "Image added" / "Save to keep it in the article.".
- "Remove image: confirm": the confirm over the editor, image id 1 (north-wall.jpg, 3.9 MB). See `editor_image_removal.qa.md`.
- "Removed": the editor without the image and the toast "Image removed" / "It's still in your library.".
Clock-sensitive: none. Dates are absolute. Nothing is skipped.
Layout facts the mocks must hit (from the frame): the dialog is 640 px at 40 px from the top; the tabs row has a 20 px gap and 24 px side padding; the body has 16 px top padding and 24 px side padding; the search field is 32 px (44 px on mobile); the grid gap is 12 px (8 px between columns on mobile).
Manual checks the screenshots can't cover:
- [ ] Focus order is tabs, search, tiles, "Load more", footer. Enter and Space select a tile. Focus returns to the toolbar's image button when the dialog closes.
- [ ] Hover on a tile and on "Load more", and the focus ring.
- [ ] Motion: the dialog follows the standard 240 ms timing.
- [ ] A real library of more than 24 images through `task dev`: "Load more" and the cursor.
- [ ] A screen reader reads the tile names and the alt text help.
## Manual verification
Real `GET /v1/media/` with `q` and `cursor` against `task dev` with a live SQLite file.
+1 -1
View File
@@ -7,7 +7,7 @@ Package: `test` (black-box). New file `test/media_library_http_test.go`. Fake ty
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.
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`).
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:
+1 -1
View File
@@ -23,7 +23,7 @@ Feature: Single article read follows the same online/offline visibility rule as
group that already holds GET /v1/article/ (features/public_article_listing.feature), so a
missing or invalid jwt cookie no longer blocks the request; POST /v1/article/,
DELETE /v1/article/{id}, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish,
PATCH /v1/article/{id}/unpublish and POST /v1/article/{id}/image keep requiring
and PATCH /v1/article/{id}/unpublish keep requiring
Verifier + Authenticator + RequireVerifiedUser, unchanged
And Handler.GetArticle reuses the existing Handler.callerIsVerified(r) helper already used by
Handler.ListArticles: when the request resolves to a verified account it calls
@@ -0,0 +1,42 @@
Feature: The legacy POST /v1/article/{id}/image alias is removed
As the maintainer of RenewCMS
I want the editor to upload through /v1/media only and the article alias deleted
So that there is one way to add an image and no dead route to keep
Background:
Given this is a Go API change in a slice that is otherwise SPA only, made at the user's request
And features/upload_article_image.feature and its .qa.md are deleted, with their tests
And api/router.go no longer mounts r.Post("/{id}/image", ...) in NewArticleRouter, and the
imageHandler variable and the api/handlers/image import are removed from it
And the package api/handlers/image (PostLegacyArticleImage, its NewHandler and its helpers) is deleted
And CreateImageUseCase and GetArticleUseCase stay: both are used by the media handler and
the article handler, and api/pkg/upload stays because the media handler uses it
And the rest of the protected article group is unchanged, still behind jwtauth.Authenticator,
RequireCurrentToken and RequireVerifiedUser
And docs/architecture.md no longer lists api/handlers/image
And POST /v1/media/ is the only upload route, and DELETE /v1/article/{id}/image/{imageId}
stays unmounted, as before
Scenario Outline: The removed routes are not found
Given a valid jwt cookie for verified user 1 and an article with id 1
When the client sends <method> <path> with a valid PNG as multipart field "file"
Then the response status is 404
And the image repository's Create is never called
And no image exists afterwards
Examples:
| method | path |
| POST | /v1/article/1/image |
| DELETE | /v1/article/1/image/10 |
Scenario: Uploading still works through the media route
Given a valid jwt cookie for verified user 1
When the client sends POST /v1/media/ with a real PNG named "before.png"
Then the response status is 201 and the body is the library item with a "url" and an "id"
And GET /v1/media/ lists it
Scenario: The rest of the article router is unchanged
Given a valid jwt cookie for verified user 1 and an article with id 1
When the client sends PATCH /v1/article/1/publish
Then the response is as before this slice
@@ -0,0 +1,21 @@
# QA procedure: remove_legacy_article_image_alias
Package: `test` (black-box, httptest, in-memory fakes, no live server).
## Setup
1. Call `validator.InitValidator()`.
2. Build the article router exactly as `NewArticleRouter` does after the change (no image handler), wrapped in `Verifier`, `Authenticator`, `RequireCurrentToken` and `RequireVerifiedUser`, over fake article, image and user repositories. Reuse `newMultipartFileRequest` and `pngBytes` from `test/media_fixtures_test.go`.
## HTTP checks
3. `POST /v1/article/1/image` with a valid PNG and a valid cookie -> 404, `Create` never called.
4. `DELETE /v1/article/1/image/10` with a valid cookie -> 404, image 10 still exists.
5. `POST /v1/media/` with a valid PNG -> 201 with `id` and `url` (this stays covered by `media_library_http_test.go`; confirm the case still passes after the deletions).
6. `PATCH /v1/article/1/publish` still answers as in `verified_account_required_http_test.go`.
## Cleanup (Go)
7. Delete `test/upload_article_image_http_test.go`.
8. In `test/verified_account_required_http_test.go`: remove the `api/handlers/image` import, the `imageHandler` construction (line 34), the route `r.Post("/{id}/image", ...)` (line 53) and the "unverified user is refused when attaching an image" case (around line 149).
9. Run `task test` and `task lint`; nothing else may reference `PostLegacyArticleImage` or `handlers/image`.
## Manual verification
With `task dev`, `curl -i -X POST /v1/article/1/image` with a valid cookie answers 404.
-47
View File
@@ -1,47 +0,0 @@
Feature: POST /v1/article/{id}/image is a thin alias that creates a library image
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 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 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 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: 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
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
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
-21
View File
@@ -1,21 +0,0 @@
# QA procedure: upload_article_image
Package: `test` (black-box, extending `article_test.go` / `image_test.go`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
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
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.
## 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.
+2 -12
View File
@@ -12,12 +12,12 @@ Feature: Protected article routes and the offline-inclusive listing require a ve
Given before this slice, the protected group inside NewArticleRouter (api/router.go) applies
only jwtauth.Verifier then jwtauth.Authenticator to GET /v1/article/{id}, POST /v1/article/,
DELETE /v1/article/{id}, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish,
PATCH /v1/article/{id}/unpublish and POST /v1/article/{id}/image, so any caller with a
PATCH /v1/article/{id}/unpublish, so any caller with a
validly signed, unexpired jwt cookie reaches the handler regardless of that account's
is_verified status
And this slice adds a third middleware, api/middleware.RequireVerifiedUser(getUser
*useCases.GetUserUseCase), mounted after jwtauth.Authenticator in that same group, so the
order becomes Verifier, Authenticator, RequireVerifiedUser for all seven routes above
order becomes Verifier, Authenticator, RequireVerifiedUser for all six routes above
And RequireVerifiedUser reads user_id from the request's jwt claims using a new exported
helper, api/pkg/claims.UserId(r *http.Request) (uint32, bool), which replaces the
identical private userIdFromClaims method api/handlers/auth.Handler.Verify and
@@ -81,16 +81,6 @@ Feature: Protected article routes and the offline-inclusive listing require a ve
| PATCH | /v1/article/1/publish |
| PATCH | /v1/article/1/unpublish |
Scenario: An unverified user is also refused when attaching an image
Given a user repository containing user id 1, is_verified false
And a valid jwt cookie for user 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"
Then the response status is 403
And the response body is the apperr.VerificationRequiredErrorMessage text
And the image repository's Create is never called
Scenario: A verified user still exercises the protected routes exactly as before
Given a user repository containing user id 1, is_verified true
And a valid jwt cookie for user id 1
+2 -7
View File
@@ -29,8 +29,7 @@ routers, so the HTTP-level checks live in a new `test/verified_account_required_
- a nested `r.Group` adding `jwtauth.Authenticator(token)` then
`middleware.RequireVerifiedUser(getUser)`, containing `r.Get("/{id}", ...)`,
`r.Post("/", ...)`, `r.Delete("/{id}", ...)`, `r.Patch("/{id}", ...)`,
`r.Patch("/{id}/publish", ...)`, `r.Patch("/{id}/unpublish", ...)`,
`r.Post("/{id}/image", ...)`
`r.Patch("/{id}/publish", ...)`, `r.Patch("/{id}/unpublish", ...)`
No live server.
8. Mint jwt cookies with `token.Encode(map[string]any{"user_id": uint32(1)})` for the valid-token
cases, following the convention in `features/article_publishing.qa.md`.
@@ -45,10 +44,6 @@ routers, so the HTTP-level checks live in a new `test/verified_account_required_
`PATCH /v1/article/1/unpublish`, each sent with the valid cookie from step 8 → assert status
`403` and body exactly `apperr.VerificationRequiredErrorMessage`. Assert the article repository
was not mutated by any of the write calls.
11. Same seed, `POST /v1/article/1/image` with the valid cookie and a multipart form field `"file"`
holding `before.png` / `image/png` → assert status `403`, body
`apperr.VerificationRequiredErrorMessage`, and the image repository's `Create` is never called
(mirroring the assertion style in `test/upload_article_image_test.go`).
## HTTP checks: a verified caller is unaffected
@@ -127,7 +122,7 @@ routers, so the HTTP-level checks live in a new `test/verified_account_required_
## Manual verification (not exercised by the Go test)
- The SPA-visible behaviour of an unverified, logged-in user landing on the back-office article
screens (create/edit/publish/image-upload) and seeing a 403 instead of the normal editing UI,
screens (create/edit/publish) and seeing a 403 instead of the normal editing UI,
and the front-office/back-office listing showing only published articles to that same unverified
session, is out of scope for this slice's automated test and should be checked by hand against a
running dev build with an unverified test account.