Files
redefined-designs/frontend/tests/e2e/admin-draft-queue.spec.ts
T
bermudalambandClaude Opus 5 dd533ffd63 feat(admin): rotate a photo from the review queue (#301)
Two icon buttons under every thumbnail, and the client for the item-scoped endpoints behind them. Icon-only with an aria-label rather than visible text, because three labelled buttons under a 120px thumbnail is more furniture than the photo — and a button with no text has no accessible name at all without one.

They are not gated on the background-removal flag. That flag is about the sidecar, and rotation has nothing to do with it: turning a photo is a local file operation that works in every environment, including one where REMBG_URL was never set.

The cache-busting src is the part most likely to have shipped broken. Rotation does not change image_path, so after a successful turn the src is byte-for-byte the string the browser already holds a copy for, and the photo would appear not to have moved. express.static is mounted with no maxAge and would serve the new bytes on a full page reload, but nothing in a session asks it to. A version held in component state is what makes the button visibly do something, and it needs no column and no server change, because the file's identity has not changed — only this page's need to see it again.

The client lives in its own module rather than in draftsApi, whose send() hardcodes the item-drafts prefix these routes deliberately do not use. The inventory editor imports this same module unchanged when it follows, which is the whole reason the endpoints went on the item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:52:20 -05:00

148 lines
6.5 KiB
TypeScript

import { test, expect, uniqueSuffix, findOrFail, createAdminContext } from './fixtures';
const RUN = uniqueSuffix();
// The 1x1 PNG the other upload specs use, so this exercises the real validated
// upload path rather than a buffer that merely starts correctly.
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
let token: string;
test.beforeAll(async ({ playwright }) => {
const api = await createAdminContext(playwright);
const res = await api.post('/api/admin/upload-links', {
data: { label: `Review queue spec ${RUN}`, email: `draft-queue-${RUN}@example.com` }
});
expect(res.status(), 'creating the upload link').toBe(201);
token = (await res.json()).token;
await api.dispose();
});
/**
* Seeded through the intake route rather than POST /api/admin/items, which
* writes no item_drafts row — an item created that way would never appear in a
* queue that joins that table. This is also the path a real submission takes.
*/
async function submitAnItem(page: import('@playwright/test').Page, note: string): Promise<void> {
await page.goto(`/submit/${token}`);
await page.setInputFiles('input[type="file"]', {
name: `${RUN}.png`,
mimeType: 'image/png',
buffer: PNG
});
await page.getByLabel('Anything you know about this item').fill(note);
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByRole('heading', { name: 'Thank you — it arrived' })).toBeVisible();
}
// Every assertion is scoped to the card this test created. The dev database
// never truncates, so a queue-wide assertion outruns its timeout and fails for
// reasons unrelated to the behaviour under test (#241).
test.describe('The review queue', () => {
test('publishes a submitted item at an edited price', async ({ page, admin }) => {
const note = `Stoneware ${RUN}`;
await submitAnItem(page, note);
await admin.open('Review queue');
// Located by the sender's note, which carries this run's id. The item's own
// name is a submission timestamp and is not unique to this test.
const card = page.locator('.ant-card').filter({ hasText: note });
await expect(card).toBeVisible();
// The provenance warning is the entire point of the screen. With no API key
// configured the draft stays queued at the 8000 default, so this is the
// default wording rather than the model's.
await expect(card.getByText(/nobody chose this/)).toBeVisible();
await card.getByLabel('Name').fill(`Blue vase ${RUN}`);
await card.getByLabel('Price').fill('95');
await card.getByRole('button', { name: 'Publish', exact: true }).click();
// Editing the price confirms it, so no confirmation dialog appears and the
// item simply publishes.
await expect(card.getByText('you set this price')).toBeVisible();
const items = await (await page.request.get('/api/admin/items')).json();
const published = findOrFail(
items as { id: number; name: string; status: string; price_cents: number }[],
(item) => item.name === `Blue vase ${RUN}`,
`the published item Blue vase ${RUN}`
);
expect(published.status).toBe('available');
expect(published.price_cents).toBe(9500);
});
// The protection this screen exists to provide. Publishing at a price nobody
// chose is allowed — but it must be a decision, not an accident.
test('asks before publishing at a price nobody chose', async ({ page, admin }) => {
const note = `Unpriced ${RUN}`;
await submitAnItem(page, note);
await admin.open('Review queue');
const card = page.locator('.ant-card').filter({ hasText: note });
await expect(card).toBeVisible();
await card.getByRole('button', { name: 'Publish', exact: true }).click();
// Matched on the dialog's accessible name rather than its text: antd nests
// the confirm title in two elements, so getByText resolves to both.
const dialog = page.getByRole('dialog', { name: 'Publish at a price nobody chose?' });
await expect(dialog).toBeVisible();
await expect(dialog.getByText('$80.00')).toBeVisible();
// Backing out must leave it unpublished.
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(card.getByText(/nobody chose this/)).toBeVisible();
});
// The control that makes a poor cut survivable. Its label is its state:
// "Remove background" until an original has been recorded, "Restore
// original" afterwards, read from one field rather than two that could
// disagree.
//
// Only the label is asserted, not a click. Pressing it would need a sidecar,
// and a test that depends on a service taking forty seconds to start is
// broken by construction — the swap itself is covered in the integration
// suite against a stub.
test('offers to remove the background on each photo', async ({ page, admin }) => {
const note = `Cutout ${RUN}`;
await submitAnItem(page, note);
await admin.open('Review queue');
const card = page.locator('.ant-card').filter({ hasText: note });
await expect(card.getByRole('button', { name: 'Remove background' })).toBeVisible();
});
// Rotation is what repairs the photos uploaded before #300 taught the
// re-encode to apply EXIF orientation rather than discard it. The 1x1 PNG
// this spec uploads is square, so there is nothing visual to assert — what is
// being proved is that the control is there and the whole path answers
// without an error.
test('offers to rotate each photo', async ({ page, admin }) => {
const note = `Sideways ${RUN}`;
await submitAnItem(page, note);
await admin.open('Review queue');
const card = page.locator('.ant-card').filter({ hasText: note });
await expect(card.getByRole('button', { name: 'Rotate left' })).toBeVisible();
await expect(card.getByRole('button', { name: 'Rotate right' })).toBeVisible();
// Asserted on the response rather than on the absence of an error toast.
// toHaveCount(0) passes the instant it is evaluated, before a failure has
// had time to appear, so it would report success on a broken round trip —
// which is barely an assertion at all. Waiting for the POST proves the
// whole path: the button is wired, the route exists, and it answered 204.
const rotated = page.waitForResponse(
(res) => res.url().includes('/rotate-right') && res.request().method() === 'POST'
);
await card.getByRole('button', { name: 'Rotate right' }).click();
expect((await rotated).status()).toBe(204);
});
});