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>
276 lines
10 KiB
TypeScript
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;
|
|
});
|
|
});
|