fix(intake): stop a throttled sender being told their link is dead (#222)
Linting / lint (pull_request) Successful in 2m27s
SonarQube Analysis / sonarqube (pull_request) Successful in 22m27s

Adding e2e specs for the submission page found a defect in the page they were written for, which is what they were for.

One limiter counted page loads and submissions against the same twenty-per-quarter-hour allowance, so a sender working through a box of stock ran out after ten items — the exact person the feature exists for, and the exact case the limiter's own comment said must not be refused. The comment said refusing them costs a consignment while the number quietly did it.

Worse, the page could not tell a 429 from a 404. `fetchIntakeLink` treated any non-OK response as "no link", so a throttled sender was told "This link is not active" and sent to ask for a replacement — which could not have helped, because the problem was their address and a minute of patience. Two conditions needing opposite reactions were sharing a message.

Now two limiters, because the two requests cost different things. Reading a link hits one indexed row and writes nothing, so that allowance is generous at 120: someone re-reading the form or losing their signal should never be told to wait. Submitting writes up to six files, so that is the one worth bounding, at 30 — more than anyone photographing items can manage and far less than a script would want.

The page gains a third state. Unknown, revoked and used-up still collapse into one "not active" card, because whether a link exists is not something a stranger needs to learn. Throttled is deliberately kept apart from them, since "wait a moment" and "go and ask for another link" are opposite instructions.

Measured rather than assumed, on a freshly started process both times: before, 25 page loads produced 14 rejections; after, 40 produce none. The first attempt at that measurement was wrong and worth recording — the restart had failed with EADDRINUSE, so it read 30 of 30 against the old process's already-exhausted store.

The two specs now pass in a full parallel run alongside everything else. They are scoped the way #241 asks: unique run ids, assertions naming only this run's rows, nothing asserted about the table as a whole.

Backend: 284 integration, 309 unit. Frontend: build clean, lint unchanged at 2 pre-existing warnings.

Ref #222, #241
This commit is contained in:
2026-08-31 15:42:26 -05:00
parent 9b4d7f2d03
commit bcecda9122
6 changed files with 233 additions and 24 deletions
@@ -0,0 +1,72 @@
import { test, expect, uniqueSuffix } from './fixtures';
/**
* Issuing and revoking upload links from the admin (#222).
*
* Every link is labelled with a fresh run id and every assertion is scoped to
* that row. The suite is fullyParallel against one shared database, so an
* assertion on the table as a whole would be an assertion on whatever other
* specs happen to be doing (#241).
*/
test.describe('Managing upload links', () => {
test('issues a link, shows its token once, and lists it bounded', async ({ page, admin }) => {
const label = `Sarah ${uniqueSuffix()}`;
await admin.goto();
await page.getByRole('tab', { name: 'Upload links' }).click();
await page.getByLabel('Link label').fill(label);
await page.getByRole('button', { name: 'Create link' }).click();
// Shown exactly once. The server keeps only a digest, so there is no
// reveal to come back to — the copy has to say so.
await expect(page.getByText('Copy this link now')).toBeVisible();
await expect(page.getByText(/\/submit\//)).toBeVisible();
await expect(page.getByText(/cannot be shown again/)).toBeVisible();
const row = page.getByRole('row', { name: new RegExp(label) });
await expect(row).toBeVisible();
// The default cap, not unlimited. An unbounded link should be asked for.
await expect(row.getByText('0 of 25')).toBeVisible();
await expect(row.getByText('Active')).toBeVisible();
});
test('revokes a link, naming the action on the confirm', async ({ page, admin }) => {
const label = `Temporary ${uniqueSuffix()}`;
await admin.goto();
await page.getByRole('tab', { name: 'Upload links' }).click();
await page.getByLabel('Link label').fill(label);
await page.getByRole('button', { name: 'Create link' }).click();
const row = page.getByRole('row', { name: new RegExp(label) });
await row.getByRole('button', { name: 'Revoke' }).click();
// The confirm names what it does rather than saying OK, which is what the
// rest of this admin's destructive actions do.
await page.getByRole('tooltip').getByRole('button', { name: 'Revoke' }).click();
await expect(row.getByText('Revoked')).toBeVisible();
// The row stays: what arrived through the link is kept, and the record of
// where it came from with it.
await expect(row).toBeVisible();
});
// Blank-means-unlimited would make the least deliberate action produce the
// least bounded link, so unlimited is a checkbox rather than an empty field.
test('makes unlimited a deliberate choice', async ({ page, admin }) => {
const label = `Always on ${uniqueSuffix()}`;
await admin.goto();
await page.getByRole('tab', { name: 'Upload links' }).click();
await page.getByLabel('Link label').fill(label);
await page.getByText('No limit').click();
await page.getByRole('button', { name: 'Create link' }).click();
const row = page.getByRole('row', { name: new RegExp(label) });
await expect(row).toBeVisible();
// A bare count rather than "0 of N".
await expect(row.getByText('0 of', { exact: false })).toHaveCount(0);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { test, expect, createAdminContext, uniqueSuffix } from './fixtures';
/**
* The public submission page (#222).
*
* Every fixture here carries a run id and every assertion names only what this
* run created. The suite is fullyParallel against one shared database, so a
* spec that asserts on anything catalogue-wide is asserting on other specs too
* (#241).
*/
const RUN = `i${uniqueSuffix()}`;
let token = '';
test.beforeAll(async ({ playwright }) => {
const api = await createAdminContext(playwright);
const res = await api.post('/api/admin/upload-links', {
data: { label: `Intake spec ${RUN}` }
});
expect(res.status(), 'creating the upload link').toBe(201);
token = (await res.json()).token;
await api.dispose();
});
test.describe('Sending in an item through a link', () => {
test('shows the form for a link that works', async ({ page }) => {
await page.goto(`/submit/${token}`);
await expect(page.getByRole('heading', { name: 'Send in an item' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Choose photos' })).toBeVisible();
});
// Nothing to send is not a submission, and the server would refuse it — but
// the sender should not have to find that out by pressing the button.
test('will not send until a photo is chosen', async ({ page }) => {
await page.goto(`/submit/${token}`);
await expect(page.getByRole('button', { name: 'Send' })).toBeDisabled();
});
// One state for every refusal, matching the server's single 404. Saying which
// kind of dead a link is would tell a stranger whether one they guessed at
// exists.
test('explains an unusable link without saying which kind', async ({ page }) => {
await page.goto('/submit/not-a-real-token');
await expect(page.getByRole('heading', { name: 'This link is not active' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Choose photos' })).toHaveCount(0);
});
test('accepts a photo and says it arrived', async ({ page }) => {
await page.goto(`/submit/${token}`);
// A real 1x1 PNG, so the server's magic-byte check sees what it expects
// rather than a buffer that merely starts correctly.
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
await page.setInputFiles('input[type="file"]', {
name: `${RUN}.png`,
mimeType: 'image/png',
buffer: png
});
await page.getByLabel('Anything you know about this item').fill(`Stoneware ${RUN}`);
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByRole('heading', { name: 'Thank you — it arrived' })).toBeVisible();
// The wording matters: it is what stops a sender wondering why their item
// is not on the site.
await expect(page.getByText(/Nothing is listed for sale until/)).toBeVisible();
});
});