The address is now a required field beside the label, the links table shows where each link was sent, and the admin is told plainly when the mail did not go — with the link still on screen to copy, which is the case that matters in QA and in local development where there is no mail at all. Two specs in unrelated features created links with only a label and the route now refuses that, so they are updated here rather than left to go red on somebody else's branch. That is the cost of making the address required, and it is a small one: the compiler and the suite find every call site. 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}`, 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();
|
|
});
|
|
});
|