Files
redefined-designs/backend/tests/integration/intakeCeiling.integration.test.ts
T
bermudalambandClaude Opus 5 5a9022d8d1 test(integration): mock the mailer everywhere an upload link is issued (#260)
intake.integration.test.ts, intakeCeiling.integration.test.ts and uploadLinks.integration.test.ts all create upload links, and since #260 that now sends real mail. env.setup.ts never clears SMTP_USER, SMTP_PASSWORD or MAIL_ALLOWLIST, so with those inherited from a developer's shell these three files opened live TLS connections to smtp.gmail.com:465 and, with no allowlist set, actually delivered to sarah@example.com. This is the exact hazard the "Do not add one back" comment in tests/unit/mailOutcome.test.ts already warns about, reintroduced at the integration layer.

All three now mock ../../src/mailer the same way accountDetails.integration.test.ts, favorites.integration.test.ts and resendVerification.integration.test.ts already do. uploadLinks.integration.test.ts is the one place that needs to see specific MailOutcome values come back through the route, so its two outcome tests were restructured to drive the mock's return value directly (sentMail.mockResolvedValueOnce(...)) instead of threading SMTP_USER/MAIL_ALLOWLIST through the real sendMail. That is a cleaner test anyway: it isolates the route's job (reporting whatever outcome sendMail returns) from sendMail's own skip logic, which is already covered hermetically by mailOutcome.test.ts and mailAllowlist.test.ts.

Also addresses the related minor finding that nothing asserted the mail actually carried the working link: required: ['submitUrl'] on the template only guards that the placeholder is present in the body, not that the route supplied a correct value for it. A new test in uploadLinks.integration.test.ts inspects the mock's captured call and asserts the html contains the created token's /submit/ URL, and covers the three submissionsAllowed phrasings (a numeric cap, a cap of exactly one, and uncapped).

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

154 lines
4.9 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';
// makeLink() issues a link on every test, which now emails it. Mocked so the
// suite never opens a real connection to smtp.gmail.com — see the "Do not add
// one back" warning in tests/unit/mailOutcome.test.ts, which this mirrors at
// the integration layer.
jest.mock('../../src/mailer', () => ({
sendMail: jest.fn().mockResolvedValue('sent')
}));
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 });
});
});