diff --git a/frontend/tests/e2e/admin-draft-queue.spec.ts b/frontend/tests/e2e/admin-draft-queue.spec.ts new file mode 100644 index 0000000..f952f83 --- /dev/null +++ b/frontend/tests/e2e/admin-draft-queue.spec.ts @@ -0,0 +1,101 @@ +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 { + 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(); + }); +});