Files
redefined-designs/backend/tests/integration/intakeCeiling.integration.test.ts
T
bermudalambandClaude Opus 5 b18b3e3a3d feat(intake): require an address for an upload link and send the link to it (#260)
Creating a link now requires a valid email address and mails the link to it, which is the whole point: getting a link to a contributor was previously a copy-and-paste into whatever the admin happened to use.

The send is awaited and its outcome reported, unlike every other sender in this codebase, which fires and forgets because nobody is waiting on the answer. Here somebody is. The admin is looking at the screen, and whether they now have to send the link by hand is exactly the thing they need to know — and QA blocks delivery to any address outside MAIL_ALLOWLIST by design, so a link that was never emailed would otherwise look precisely like one that was.

A send that could not happen does not roll the link back. The token is displayed exactly once, so a rollback would leave the admin retrying and holding a different link, discarding work that had succeeded. They end up with a usable link and an honest statement about delivery instead.

One inaccuracy left deliberately: an SMTP rejection is reported as skipped-unconfigured rather than a fourth outcome of its own. The distinction is real but nothing consumes it, and the admin's next action is identical either way.

Also updates the other integration tests that created a link with only a label, since an address is now required, and adds the uploadLink template key that GET /api/admin/email-templates was missing from its list — an omission left by the template's addition in the prior commit on this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:00:15 -05:00

146 lines
4.6 KiB
TypeScript

import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
import { resetAlertThrottleForTests } from '../../src/intake/abuseAlert';
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
beforeEach(async () => {
await resetDb();
resetAlertThrottleForTests();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
async function makeLink(): Promise<string> {
const res = await request(app)
.post('/api/admin/upload-links')
.send({ label: 'ceiling spec', email: 'sarah@example.com' });
expect(res.status).toBe(201);
return res.body.token;
}
/** Fills the window with drafts, as though earlier submissions had arrived. */
async function fillWindow(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO items (name, status) VALUES ('filler', 'pending') RETURNING id`
);
await pool.query(`INSERT INTO item_drafts (item_id) VALUES ($1)`, [rows[0]!.id]);
}
}
async function setCeiling(value: number): Promise<void> {
await pool.query(
`INSERT INTO admin_settings (key, value, updated_at) VALUES ('intake_daily_ceiling', $1, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
[String(value)]
);
}
const submit = (token: string) =>
request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png');
const draftCount = async (): Promise<number> =>
(await pool.query<{ c: number }>(`SELECT count(*)::int AS c FROM item_drafts`)).rows[0]?.c ?? 0;
describe('the submission ceiling', () => {
it('accepts a submission below the ceiling', async () => {
await setCeiling(5);
const token = await makeLink();
expect((await submit(token)).status).toBe(201);
});
it('refuses with 503 once the ceiling is reached', async () => {
await setCeiling(2);
await fillWindow(2);
const token = await makeLink();
const res = await submit(token);
expect(res.status).toBe(503);
expect(res.body.error).toMatch(/later/i);
});
// Nothing the sender did is wrong and the condition clears by itself, so this
// must not read as a rejection of them or of their link.
it('leaves the link usable, so it works again when there is room', async () => {
await setCeiling(1);
await fillWindow(1);
const token = await makeLink();
expect((await submit(token)).status).toBe(503);
await setCeiling(50);
expect((await submit(token)).status).toBe(201);
});
// A refused submission must write nothing. The check is ordered ahead of
// uploadImages for the same reason requireUsableLink is.
it('stores no item when it refuses', async () => {
await setCeiling(1);
await fillWindow(1);
const token = await makeLink();
await submit(token);
expect(await draftCount()).toBe(1);
});
/**
* The constraint that matters most. Intake being throttled is an
* inconvenience; the shop being unable to add its own stock is an outage.
*/
it('never applies to the admin upload path', async () => {
await setCeiling(1);
await fillWindow(5);
const res = await request(app)
.post('/api/admin/items')
.field('name', 'admin adds stock')
.field('price', '42.00')
.attach('images', PNG, 'a.png');
// 200, not 201 — this route answers with the created item rather than a
// bare created status, unlike the intake route.
expect(res.status).toBe(200);
});
it('accepts again after the window is reset', async () => {
await setCeiling(1);
await fillWindow(1);
const token = await makeLink();
expect((await submit(token)).status).toBe(503);
const reset = await request(app).post('/api/admin/upload-links/reset-ceiling');
expect(reset.status).toBe(200);
expect((await submit(token)).status).toBe(201);
});
// A reset forgives; it does not erase. Those submissions are real and their
// items are in the review queue.
it('keeps the submissions it counted after a reset', async () => {
await fillWindow(3);
await request(app).post('/api/admin/upload-links/reset-ceiling');
expect(await draftCount()).toBe(3);
});
// reset-ceiling is declared above /:id/revoke, so Express must not read it as
// an id and try to revoke a link called "reset-ceiling".
it('does not collide with the revoke route', async () => {
const res = await request(app).post('/api/admin/upload-links/reset-ceiling');
expect(res.body).toEqual({ reset: true });
});
});