Two items published from the Review queue did not appear in Inventory. Publishing was never at fault: the items were published, the rows were right, and the API returned them. The Inventory tab was showing a list it had fetched earlier and never refreshed. antd keeps a tab pane mounted once it has been rendered, and Inventory is the default tab, so its pane mounts at page load whether or not anyone looks at it. Its only fetch runs from an effect depending on the filters, so it fires on mount and when a filter changes and at no other time. Open the admin, switch to the Review queue, publish, switch back, and nothing has re-run — the table still holds the list built before the submissions existed. A browser reload shows them, which is what makes this read as publishing being broken rather than as a stale table. The tab is controlled now and tells Inventory whether it is the one on screen, and Inventory refetches when that becomes true. Guarded on visibility rather than fetching unconditionally, because the pane lives for the life of the page and would otherwise keep refetching while hidden. destroyInactiveTabPane on the Tabs would also have fixed it, by remounting, and was rejected: it discards every tab's state on every switch — filters, scroll position, a half-filled form — and refetches all of them repeatedly, which is a much larger behavioural change than this bug is worth. The existing publish test passes against the broken behaviour, and that is the reason this went unnoticed. It asserts against GET /api/admin/items, and the API was always correct. The new test asserts through the UI and never reloads the page: Inventory is opened before publishing, so its list is fetched while the item still carries its submission-timestamp name, and the assertion afterwards looks for the name given at publish — which a stale list cannot contain. A reload anywhere in it would make it pass against the bug it exists to catch. AdminPage's comment claimed only the active panel is mounted. It is only the active panel that is visible; the rest stay in the DOM. That mistaken belief is the shape of this bug, so the comment now says which it is and why it matters. Not fixed here: Categories, Tags, Upload links and Customers are mounted-once children of the same Tabs and are all changeable from elsewhere, so they very likely share this. Recorded on the issue rather than assumed to be fine. Verified: tsc clean for src and tests, lint 0 errors, production build green. The new test needs CI to run — the e2e suite wants a browser and a database this machine cannot provide. Closes #327 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
8.7 KiB
TypeScript
197 lines
8.7 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);
|
|
});
|
|
|
|
/**
|
|
* The bug reported from QA in #327: two items were published and did not
|
|
* appear in Inventory.
|
|
*
|
|
* The publish itself was never at fault, which is why the test above passes —
|
|
* it asserts against `GET /api/admin/items`, and the API was always right.
|
|
* What was wrong was the Inventory tab: antd keeps a pane mounted once it has
|
|
* been rendered, so a tab opened at page load and returned to later refetches
|
|
* nothing and shows the list it built the first time.
|
|
*
|
|
* So this asserts through the UI and, crucially, **never reloads the page**.
|
|
* Opening Inventory before publishing is what arms it: the list is fetched
|
|
* while the item still has its submission-timestamp name, and the assertion
|
|
* afterwards looks for the name given at publish — which a stale list cannot
|
|
* contain. A `page.reload()` anywhere in here would make it pass against the
|
|
* broken behaviour.
|
|
*/
|
|
test('an item published from the queue appears in Inventory without a reload', async ({
|
|
page,
|
|
admin
|
|
}) => {
|
|
const note = `Tab staleness ${RUN}`;
|
|
const publishedName = `Vase ${RUN}`;
|
|
await submitAnItem(page, note);
|
|
|
|
// Inventory first, so its pane is mounted and its list fetched before the
|
|
// publish happens. This is the state a real admin is in: the tab has been
|
|
// open since they arrived.
|
|
await admin.open('Inventory');
|
|
await expect(admin.activeTable).toBeVisible();
|
|
|
|
await admin.openTab('Review queue');
|
|
const card = page.locator('.ant-card').filter({ hasText: note });
|
|
await expect(card).toBeVisible();
|
|
|
|
await card.getByLabel('Name').fill(publishedName);
|
|
// Setting a price confirms it, so publishing does not stop on the
|
|
// unconfirmed-price dialog the test above covers.
|
|
await card.getByLabel('Price').fill('42');
|
|
await card.getByRole('button', { name: 'Publish', exact: true }).click();
|
|
await expect(card.getByText('you set this price')).toBeVisible();
|
|
|
|
await admin.openTab('Inventory');
|
|
|
|
// Sorted by created_at descending and submitted moments ago, so it is on the
|
|
// first page rather than somewhere in the accumulated dev database.
|
|
await expect(admin.activePanel.getByText(publishedName)).toBeVisible();
|
|
});
|
|
});
|