Files
redefined-designs/backend/tests/integration/intake.integration.test.ts
T
bermudalambandClaude Opus 5 885a78c572 feat(intake): record whether the submitter asked for a cut-out (#281)
The public intake POST now reads a `removeBackground` multipart field and stores it on the new `item_drafts.remove_background` column. The checkbox on the submission page is ticked by default, so a client that sends nothing gets `true` — only the exact string `'false'` opts out, so a stray or unexpected value is treated as consent rather than a silent refusal.

The GET now also reports `backgroundRemoval: isRembgConfigured()` alongside the label, so the submission page knows up front whether the feature exists in this environment at all. Neither handler calls the sidecar or the AI — this task only records intent for the drafting worker to act on later, and the existing ordering of `requireUsableLink` and `requireCapacity` ahead of `uploadImages` is unchanged.

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

276 lines
10 KiB
TypeScript

import request from 'supertest';
import { promises as fs } from 'fs';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
const UPLOADS_DIR = process.env.UPLOADS_DIR as string;
// The same 1x1 PNG the upload validation suite uses, so the accepted case
// exercises the whole path rather than a buffer that merely starts right.
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
// multer.diskStorage does not create its destination.
beforeAll(async () => {
await fs.mkdir(UPLOADS_DIR, { recursive: true });
});
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
async function storedFiles(): Promise<string[]> {
return fs.readdir(UPLOADS_DIR);
}
async function issueLink(label = 'Sarah', maxSubmissions?: number | null): Promise<string> {
const res = await request(app)
.post('/api/admin/upload-links')
.send({ label, ...(maxSubmissions === undefined ? {} : { maxSubmissions }) });
expect(res.status).toBe(201);
return res.body.token as string;
}
/** A submission with optional extra multipart fields beside the photo. */
function postPhoto(token: string, fields: Record<string, string> = {}) {
const req = request(app).post(`/api/intake/${token}`);
for (const [name, value] of Object.entries(fields)) void req.field(name, value);
return req.attach('images', PNG, 'a.png');
}
describe('checking a link before showing the form', () => {
it('names the link so the page can greet the sender', async () => {
const token = await issueLink('Sarah');
const res = await request(app).get(`/api/intake/${token}`);
expect(res.status).toBe(200);
expect(res.body.label).toBe('Sarah');
});
// 404 rather than 403 throughout: whether a link exists is not something a
// stranger needs to be able to distinguish. Same reasoning as uploads.ts.
it('404s an unknown token', async () => {
const res = await request(app).get('/api/intake/not-a-real-token');
expect(res.status).toBe(404);
});
it('404s a revoked link', async () => {
const token = await issueLink();
const { rows } = await pool.query<{ id: number }>(`SELECT id FROM upload_links`);
await request(app).post(`/api/admin/upload-links/${rows[0]?.id}/revoke`);
const res = await request(app).get(`/api/intake/${token}`);
expect(res.status).toBe(404);
});
});
describe('submitting an item', () => {
it('creates a pending item with its images, note and provenance', async () => {
const token = await issueLink('Sarah');
const res = await request(app)
.post(`/api/intake/${token}`)
.field('note', 'Hand-thrown stoneware, chip on the base')
.attach('images', PNG, 'front.png')
.attach('images', PNG, 'back.png');
expect(res.status).toBe(201);
expect(res.body.ok).toBe(true);
const { rows: items } = await pool.query<{ id: number; status: string; price_cents: number }>(
`SELECT id, status, price_cents FROM items`
);
expect(items).toHaveLength(1);
expect(items[0]?.status).toBe('pending');
// The migration's default, not a price anyone chose.
expect(items[0]?.price_cents).toBe(8000);
const { rows: images } = await pool.query<{ image_path: string }>(
`SELECT image_path FROM item_images WHERE item_id = $1 ORDER BY sort_order`,
[items[0]?.id]
);
expect(images).toHaveLength(2);
expect(images[0]?.image_path).toMatch(/^\/uploads\/[a-f0-9-]+\.png$/);
const { rows: drafts } = await pool.query(
`SELECT submitter_note, state, price_source, upload_link_id
FROM item_drafts WHERE item_id = $1`,
[items[0]?.id]
);
expect(drafts[0]?.submitter_note).toBe('Hand-thrown stoneware, chip on the base');
expect(drafts[0]?.state).toBe('queued');
expect(drafts[0]?.price_source).toBe('default');
expect(drafts[0]?.upload_link_id).not.toBeNull();
});
it('counts the submission against the link', async () => {
const token = await issueLink();
await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png');
const { rows } = await pool.query<{ submission_count: number; last_used_at: string | null }>(
`SELECT submission_count, last_used_at FROM upload_links`
);
expect(rows[0]?.submission_count).toBe(1);
expect(rows[0]?.last_used_at).not.toBeNull();
});
it('refuses a submission with no photos', async () => {
const token = await issueLink();
const res = await request(app).post(`/api/intake/${token}`).field('note', 'nothing attached');
expect(res.status).toBe(400);
const { rows } = await pool.query(`SELECT id FROM items`);
expect(rows).toHaveLength(0);
});
// The file is named .png and declared image/png, but the bytes are not.
// This is the check that cannot happen before the write.
it('refuses a file whose bytes disagree with its type', async () => {
const token = await issueLink();
const res = await request(app)
.post(`/api/intake/${token}`)
.attach('images', Buffer.from('<html>not an image</html>'), {
filename: 'evil.png',
contentType: 'image/png'
});
expect(res.status).toBe(400);
const { rows } = await pool.query(`SELECT id FROM items`);
expect(rows).toHaveLength(0);
});
it('404s a revoked link without creating anything', async () => {
const token = await issueLink();
const { rows: links } = await pool.query<{ id: number }>(`SELECT id FROM upload_links`);
await request(app).post(`/api/admin/upload-links/${links[0]?.id}/revoke`);
const res = await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png');
expect(res.status).toBe(404);
const { rows } = await pool.query(`SELECT id FROM items`);
expect(rows).toHaveLength(0);
});
// The reason requireUsableLink is ordered ahead of uploadImages. Without that
// ordering this still returns 404 and still creates no item — the bytes just
// reach the disk first and are deleted afterwards. This asserts they never
// arrive, so a future reordering fails here rather than quietly handing an
// unauthenticated caller control of disk churn.
it('writes nothing to the uploads volume for a token that does not work', async () => {
const before = await storedFiles();
const res = await request(app)
.post('/api/intake/not-a-real-token')
.attach('images', PNG, 'a.png');
expect(res.status).toBe(404);
expect(await storedFiles()).toEqual(before);
});
it('stops accepting once the link hits its cap', async () => {
const token = await issueLink('One shot', 1);
const first = await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png');
expect(first.status).toBe(201);
const second = await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'b.png');
expect(second.status).toBe(404);
const { rows } = await pool.query(`SELECT id FROM items`);
expect(rows).toHaveLength(1);
});
// #226 applies here too, and this route is exactly where it matters most:
// the photo comes from a stranger's phone rather than the shop's own camera.
it('strips metadata from a submitted photo', async () => {
const token = await issueLink();
await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png');
const { rows } = await pool.query<{ image_path: string }>(`SELECT image_path FROM item_images`);
const sharp = (await import('sharp')).default;
const stored = await sharp(
`${UPLOADS_DIR}/${rows[0]?.image_path.replace('/uploads/', '')}`
).metadata();
expect(stored.exif).toBeUndefined();
});
});
describe('a submitted item does not reach the storefront', () => {
it('is absent from the public catalogue', async () => {
const token = await issueLink();
await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png');
const res = await request(app).get('/api/items');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(0);
});
});
describe('the background-removal intent', () => {
// Ticked by default on the page, so absent means yes. An older client or a
// curl call then behaves like the current default rather than silently
// opting out of something every other submission gets.
it('defaults to true when the field is not sent', async () => {
const token = await issueLink();
await postPhoto(token);
const { rows } = await pool.query<{ remove_background: boolean }>(
`SELECT remove_background FROM item_drafts ORDER BY id DESC LIMIT 1`
);
expect(rows[0]?.remove_background).toBe(true);
});
it('records a submitter who unticked it', async () => {
const token = await issueLink();
await postPhoto(token, { removeBackground: 'false' });
const { rows } = await pool.query<{ remove_background: boolean }>(
`SELECT remove_background FROM item_drafts ORDER BY id DESC LIMIT 1`
);
expect(rows[0]?.remove_background).toBe(false);
});
// Only the exact string opts out. A stray value is not a considered "no",
// and reading it as one would quietly deny somebody something they asked for.
it('treats anything other than "false" as consent', async () => {
const token = await issueLink();
await postPhoto(token, { removeBackground: 'no' });
const { rows } = await pool.query<{ remove_background: boolean }>(
`SELECT remove_background FROM item_drafts ORDER BY id DESC LIMIT 1`
);
expect(rows[0]?.remove_background).toBe(true);
});
});
describe('what the submission page is told', () => {
it('says the feature is off when there is no sidecar', async () => {
delete process.env.REMBG_URL;
const token = await issueLink();
const res = await request(app).get(`/api/intake/${token}`);
expect(res.status).toBe(200);
expect(res.body.backgroundRemoval).toBe(false);
});
it('says it is on when there is one', async () => {
process.env.REMBG_URL = 'http://rembg-syn:7000';
const token = await issueLink();
const res = await request(app).get(`/api/intake/${token}`);
expect(res.body.backgroundRemoval).toBe(true);
delete process.env.REMBG_URL;
});
});