feat: media picker and image removal
This commit is contained in:
1 parent
008b0dfbb9
commit
b3793d5dbb
54 files changed
+3769
-1412
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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`.
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user