Adds the per-photo control that closes out the background-removal feature: each photo in the review queue now gets a "Remove background" or "Restore original" button, whichever matches its current state, and the button only appears when the server reports a sidecar is configured. The label is read from original_image_path alone rather than a second flag, so there is nothing that could disagree with what the button actually does.
draftsApi.ts's fetchDrafts now returns { drafts, backgroundRemoval } instead of a bare Draft[], matching the breaking change Task 6 made to GET /api/admin/item-drafts. DraftImage gains original_image_path, and a new setImageBackground(itemId, imageId, action) posts to the remove-background/restore-original endpoints, preferring the server's error message the same way publishDraft does.
Also updates docs/ops/image-background-removal-stack.md: the status line no longer says "evaluated, not adopted", since the feature is adopted here, and the closing "If this is adopted" section is replaced with "How the application uses it", describing the two real entry points (the drafting worker's default-on checkbox, and this per-photo control) and confirming that nothing in the feature deletes a file or a row.
Adds an e2e case asserting the button's label appears on a freshly submitted item's card, scoped to that card by the sender's note per #241. It is unrun in this environment — the local stack was not started, per standing instruction not to run start-local.ps1 or Playwright without the user's supervision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 lines
5.1 KiB
TypeScript
121 lines
5.1 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}` }
|
|
});
|
|
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();
|
|
});
|
|
});
|