backgroundRemoval.integration.test.ts exported seedSubmission for no reason — nothing imports it, since draftingBackgroundRemoval.integration.test.ts and adminItemDrafts.integration.test.ts each wrote their own seeding helpers. Dropped the export, kept the function for local use. All three of these suites create a temporary uploads directory with mkdtemp and point UPLOADS_DIR at it, but none of them removed the directory afterward or restored the previous UPLOADS_DIR value — checked and the leak existed in all three, not just the one the review flagged. Each afterEach now removes its temp directory with fs.rm and restores (or deletes) UPLOADS_DIR to what it held before the test touched it, so this suite no longer leaves rubbish in the OS temp directory or a stale environment variable for whatever runs after it in the same process. This is test scaffolding cleanup, not a feature change — no runtime path in the application deletes anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
524 lines
19 KiB
TypeScript
524 lines
19 KiB
TypeScript
import request from 'supertest';
|
||
import app from '../../src/app';
|
||
import { pool } from '../../src/db';
|
||
import { resetDb, closeDb } from './setup/testDb';
|
||
import http from 'http';
|
||
import { AddressInfo } from 'net';
|
||
import { promises as fsp } from 'fs';
|
||
import os from 'os';
|
||
import path from 'path';
|
||
import * as backgroundRemoval from '../../src/intake/backgroundRemoval';
|
||
|
||
beforeEach(async () => {
|
||
await resetDb();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await pool.end();
|
||
await closeDb();
|
||
});
|
||
|
||
interface SeedOptions {
|
||
state?: string;
|
||
aiName?: string | null;
|
||
priceSource?: string;
|
||
}
|
||
|
||
/**
|
||
* A submission as the intake route leaves it: a pending item priced at the
|
||
* migration's 8000 default, a draft beside it, and one photo.
|
||
*/
|
||
async function seedDraft(overrides: SeedOptions = {}): Promise<number> {
|
||
const { rows } = await pool.query<{ id: number }>(
|
||
`INSERT INTO items (name, status) VALUES ('Submission 2026-09-01', 'pending') RETURNING id`
|
||
);
|
||
const itemId = rows[0]!.id;
|
||
|
||
await pool.query(
|
||
`INSERT INTO item_drafts (item_id, submitter_note, state, ai_name, ai_description, price_source)
|
||
VALUES ($1, 'found in a loft', $2, $3, 'A blue vase.', $4)`,
|
||
[
|
||
itemId,
|
||
overrides.state ?? 'ready',
|
||
overrides.aiName === undefined ? 'Blue vase' : overrides.aiName,
|
||
overrides.priceSource ?? 'ai'
|
||
]
|
||
);
|
||
await pool.query(
|
||
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, '/uploads/a.jpg', 0)`,
|
||
[itemId]
|
||
);
|
||
return itemId;
|
||
}
|
||
|
||
const itemIds = (body: { drafts: { item_id: number }[] }): number[] =>
|
||
body.drafts.map((draft) => draft.item_id);
|
||
|
||
describe('GET /api/admin/item-drafts', () => {
|
||
it('returns the draft with its item, photos and note', async () => {
|
||
const itemId = await seedDraft();
|
||
|
||
const res = await request(app).get('/api/admin/item-drafts');
|
||
|
||
expect(res.status).toBe(200);
|
||
const draft = res.body.drafts.find((d: { item_id: number }) => d.item_id === itemId);
|
||
expect(draft).toBeDefined();
|
||
expect(draft.submitter_note).toBe('found in a loft');
|
||
expect(draft.ai_name).toBe('Blue vase');
|
||
expect(draft.price_cents).toBe(8000);
|
||
expect(draft.price_source).toBe('ai');
|
||
expect(draft.images).toHaveLength(1);
|
||
});
|
||
|
||
// The token digest lives on upload_links and must never be selected into a
|
||
// response. Spelling the columns is what prevents that; this is the assertion
|
||
// that keeps it spelled.
|
||
it('never sends the upload link token digest', async () => {
|
||
await seedDraft();
|
||
|
||
const res = await request(app).get('/api/admin/item-drafts');
|
||
|
||
expect(JSON.stringify(res.body)).not.toContain('token_hash');
|
||
});
|
||
|
||
it('filters by state', async () => {
|
||
const ready = await seedDraft({ state: 'ready' });
|
||
const failed = await seedDraft({ state: 'failed' });
|
||
|
||
const res = await request(app).get('/api/admin/item-drafts?state=failed');
|
||
|
||
expect(itemIds(res.body)).toContain(failed);
|
||
expect(itemIds(res.body)).not.toContain(ready);
|
||
});
|
||
|
||
// Discarded is recoverable, so it has to be reachable — but it must not sit
|
||
// in the default view competing with work that still needs doing.
|
||
it('hides discarded drafts unless they are asked for', async () => {
|
||
const discarded = await seedDraft({ state: 'discarded' });
|
||
|
||
const def = await request(app).get('/api/admin/item-drafts');
|
||
expect(itemIds(def.body)).not.toContain(discarded);
|
||
|
||
const asked = await request(app).get('/api/admin/item-drafts?state=discarded');
|
||
expect(itemIds(asked.body)).toContain(discarded);
|
||
});
|
||
|
||
/**
|
||
* The gate is disabled when ADMIN_GATE_SECRET is unset, which is how the rest
|
||
* of this suite runs, so it is set here for the length of this test alone.
|
||
* Worth asserting: the gate goes on the mount in app.ts rather than inside the
|
||
* router, and leaving it off a new mount is a silent hole.
|
||
*/
|
||
describe('with the admin gate configured', () => {
|
||
const original = process.env.ADMIN_GATE_SECRET;
|
||
|
||
beforeAll(() => {
|
||
process.env.ADMIN_GATE_SECRET = 'integration-secret';
|
||
});
|
||
|
||
afterAll(() => {
|
||
if (original === undefined) delete process.env.ADMIN_GATE_SECRET;
|
||
else process.env.ADMIN_GATE_SECRET = original;
|
||
});
|
||
|
||
it('refuses a request with no gate header', async () => {
|
||
await seedDraft();
|
||
const res = await request(app).get('/api/admin/item-drafts');
|
||
expect(res.status).toBe(403);
|
||
});
|
||
|
||
it('allows a request carrying the secret', async () => {
|
||
await seedDraft();
|
||
const res = await request(app)
|
||
.get('/api/admin/item-drafts')
|
||
.set('X-Admin-Gate', 'integration-secret');
|
||
expect(res.status).toBe(200);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('POST /api/admin/item-drafts/:itemId/publish', () => {
|
||
const body = { name: 'Blue stoneware vase', description: 'Chipped base.', priceCents: 9500 };
|
||
|
||
it('writes the edited copy onto the item and publishes it', async () => {
|
||
const itemId = await seedDraft();
|
||
|
||
const res = await request(app).post(`/api/admin/item-drafts/${itemId}/publish`).send(body);
|
||
|
||
expect(res.status).toBe(200);
|
||
const { rows } = await pool.query(
|
||
`SELECT name, description, price_cents, status FROM items WHERE id = $1`,
|
||
[itemId]
|
||
);
|
||
expect(rows[0]).toMatchObject({
|
||
name: 'Blue stoneware vase',
|
||
description: 'Chipped base.',
|
||
price_cents: 9500,
|
||
status: 'available'
|
||
});
|
||
});
|
||
|
||
// The transition priceSource.ts defines, asserted end to end: a changed
|
||
// number is now the admin's responsibility.
|
||
it('records an edited price as the admin choice', async () => {
|
||
const itemId = await seedDraft();
|
||
|
||
await request(app).post(`/api/admin/item-drafts/${itemId}/publish`).send(body);
|
||
|
||
const { rows } = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [
|
||
itemId
|
||
]);
|
||
expect(rows[0]?.price_source).toBe('admin');
|
||
});
|
||
|
||
// And the case that matters more: publishing without touching the number must
|
||
// leave it recorded as unconfirmed rather than quietly claiming it was chosen.
|
||
it('leaves an untouched price unconfirmed', async () => {
|
||
const itemId = await seedDraft();
|
||
|
||
await request(app)
|
||
.post(`/api/admin/item-drafts/${itemId}/publish`)
|
||
.send({ ...body, priceCents: 8000 });
|
||
|
||
const { rows } = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [
|
||
itemId
|
||
]);
|
||
expect(rows[0]?.price_source).toBe('ai');
|
||
});
|
||
|
||
it('refuses a publish with no name, and leaves the item unpublished', async () => {
|
||
const itemId = await seedDraft();
|
||
|
||
const res = await request(app)
|
||
.post(`/api/admin/item-drafts/${itemId}/publish`)
|
||
.send({ ...body, name: ' ' });
|
||
|
||
expect(res.status).toBe(400);
|
||
const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]);
|
||
expect(rows[0]?.status).toBe('pending');
|
||
});
|
||
|
||
it('refuses a negative price', async () => {
|
||
const itemId = await seedDraft();
|
||
const res = await request(app)
|
||
.post(`/api/admin/item-drafts/${itemId}/publish`)
|
||
.send({ ...body, priceCents: -1 });
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('refuses a fractional price', async () => {
|
||
const itemId = await seedDraft();
|
||
const res = await request(app)
|
||
.post(`/api/admin/item-drafts/${itemId}/publish`)
|
||
.send({ ...body, priceCents: 95.5 });
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('404s for an item with no draft', async () => {
|
||
const { rows } = await pool.query<{ id: number }>(
|
||
`INSERT INTO items (name) VALUES ('ordinary item') RETURNING id`
|
||
);
|
||
const res = await request(app)
|
||
.post(`/api/admin/item-drafts/${rows[0]!.id}/publish`)
|
||
.send(body);
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
|
||
describe('the other three actions', () => {
|
||
// Back to queued, and attempts cleared — otherwise a draft that already failed
|
||
// three times is re-queued into a state the worker will not pick up, and the
|
||
// button does nothing with nothing anywhere to say why.
|
||
it('regenerate re-queues a failed draft and clears its attempts', async () => {
|
||
const itemId = await seedDraft({ state: 'failed' });
|
||
await pool.query(`UPDATE item_drafts SET attempts = 3, ai_error = 'boom' WHERE item_id = $1`, [
|
||
itemId
|
||
]);
|
||
|
||
const res = await request(app).post(`/api/admin/item-drafts/${itemId}/regenerate`);
|
||
|
||
expect(res.status).toBe(200);
|
||
const { rows } = await pool.query(
|
||
`SELECT state, attempts, ai_error FROM item_drafts WHERE item_id = $1`,
|
||
[itemId]
|
||
);
|
||
expect(rows[0]).toMatchObject({ state: 'queued', attempts: 0, ai_error: null });
|
||
});
|
||
|
||
it('discard marks the draft and leaves the item unpublished', async () => {
|
||
const itemId = await seedDraft();
|
||
|
||
const res = await request(app).post(`/api/admin/item-drafts/${itemId}/discard`);
|
||
|
||
expect(res.status).toBe(200);
|
||
const draft = await pool.query(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId]);
|
||
expect(draft.rows[0]?.state).toBe('discarded');
|
||
const item = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]);
|
||
expect(item.rows[0]?.status).toBe('pending');
|
||
});
|
||
|
||
// The reason discard is allowed to be a single click.
|
||
it('discard does not delete the item or its photos', async () => {
|
||
const itemId = await seedDraft();
|
||
|
||
await request(app).post(`/api/admin/item-drafts/${itemId}/discard`);
|
||
|
||
const item = await pool.query(`SELECT id FROM items WHERE id = $1`, [itemId]);
|
||
expect(item.rows).toHaveLength(1);
|
||
const images = await pool.query(`SELECT id FROM item_images WHERE item_id = $1`, [itemId]);
|
||
expect(images.rows).toHaveLength(1);
|
||
});
|
||
|
||
it('discard unpublishes an item that had already been published', async () => {
|
||
const itemId = await seedDraft();
|
||
await pool.query(`UPDATE items SET status = 'available' WHERE id = $1`, [itemId]);
|
||
|
||
await request(app).post(`/api/admin/item-drafts/${itemId}/discard`);
|
||
|
||
const item = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]);
|
||
expect(item.rows[0]?.status).toBe('pending');
|
||
});
|
||
|
||
it('restore brings a discarded draft back as ready', async () => {
|
||
const itemId = await seedDraft({ state: 'discarded' });
|
||
|
||
const res = await request(app).post(`/api/admin/item-drafts/${itemId}/restore`);
|
||
|
||
expect(res.status).toBe(200);
|
||
const { rows } = await pool.query(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId]);
|
||
expect(rows[0]?.state).toBe('ready');
|
||
});
|
||
|
||
// A submission discarded before it was ever drafted has no copy, and must not
|
||
// return claiming to have one.
|
||
it('restore returns an undrafted submission to failed, not ready', async () => {
|
||
const itemId = await seedDraft({ state: 'discarded', aiName: null });
|
||
|
||
await request(app).post(`/api/admin/item-drafts/${itemId}/restore`);
|
||
|
||
const { rows } = await pool.query(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId]);
|
||
expect(rows[0]?.state).toBe('failed');
|
||
});
|
||
|
||
it('404s each action for an item with no draft', async () => {
|
||
const { rows } = await pool.query<{ id: number }>(
|
||
`INSERT INTO items (name) VALUES ('ordinary item') RETURNING id`
|
||
);
|
||
for (const action of ['regenerate', 'discard', 'restore']) {
|
||
const res = await request(app).post(`/api/admin/item-drafts/${rows[0]!.id}/${action}`);
|
||
expect(res.status).toBe(404);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('the review queue’s background-removal control', () => {
|
||
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
||
const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||
|
||
let uploads = '';
|
||
let stub: http.Server | null = null;
|
||
let previousUploadsDir: string | undefined;
|
||
|
||
/** A stub sidecar on an ephemeral port, and a temporary uploads directory. */
|
||
async function startStub(status: number, body: Buffer | string): Promise<void> {
|
||
uploads = await fsp.mkdtemp(path.join(os.tmpdir(), 'adminbg-'));
|
||
previousUploadsDir = process.env.UPLOADS_DIR;
|
||
process.env.UPLOADS_DIR = uploads;
|
||
|
||
stub = http.createServer((req, res) => {
|
||
req.on('data', () => undefined);
|
||
req.on('end', () => {
|
||
res.writeHead(status, { 'Content-Type': 'image/png' });
|
||
res.end(body);
|
||
});
|
||
});
|
||
await new Promise<void>((resolve) => stub!.listen(0, '127.0.0.1', resolve));
|
||
process.env.REMBG_URL = `http://127.0.0.1:${(stub!.address() as AddressInfo).port}`;
|
||
}
|
||
|
||
afterEach(async () => {
|
||
delete process.env.REMBG_URL;
|
||
if (stub) {
|
||
await new Promise<void>((resolve) => stub!.close(() => resolve()));
|
||
stub = null;
|
||
}
|
||
if (uploads !== '') {
|
||
await fsp.rm(uploads, { recursive: true, force: true });
|
||
uploads = '';
|
||
}
|
||
if (previousUploadsDir === undefined) delete process.env.UPLOADS_DIR;
|
||
else process.env.UPLOADS_DIR = previousUploadsDir;
|
||
previousUploadsDir = undefined;
|
||
});
|
||
|
||
/** A ready draft with one photo, on disk, named to match the assertions. */
|
||
async function seedDraftWithImage(): Promise<{ itemId: number; imageId: number }> {
|
||
const { rows } = await pool.query<{ id: number }>(
|
||
`INSERT INTO items (name, status) VALUES ('Submission placeholder', 'pending') RETURNING id`
|
||
);
|
||
const itemId = rows[0]!.id;
|
||
await pool.query(`INSERT INTO item_drafts (item_id, state) VALUES ($1, 'ready')`, [itemId]);
|
||
const image = await pool.query<{ id: number }>(
|
||
`INSERT INTO item_images (item_id, image_path, sort_order)
|
||
VALUES ($1, '/uploads/original.jpg', 0) RETURNING id`,
|
||
[itemId]
|
||
);
|
||
if (uploads !== '') await fsp.writeFile(path.join(uploads, 'original.jpg'), JPEG_BYTES);
|
||
return { itemId, imageId: image.rows[0]!.id };
|
||
}
|
||
|
||
it('says whether there is a sidecar behind the control at all', async () => {
|
||
process.env.REMBG_URL = 'http://rembg-syn:7000';
|
||
const on = await request(app).get('/api/admin/item-drafts');
|
||
expect(on.body.backgroundRemoval).toBe(true);
|
||
|
||
delete process.env.REMBG_URL;
|
||
const off = await request(app).get('/api/admin/item-drafts');
|
||
expect(off.body.backgroundRemoval).toBe(false);
|
||
});
|
||
|
||
// The UI decides between "Remove background" and "Restore original" from
|
||
// this field alone, so it has to be in the payload the queue is built from.
|
||
it('includes original_image_path on every image', async () => {
|
||
await seedDraftWithImage();
|
||
|
||
const res = await request(app).get('/api/admin/item-drafts');
|
||
|
||
expect(res.body.drafts[0].images[0]).toHaveProperty('original_image_path', null);
|
||
});
|
||
|
||
it('cuts out one photo and answers with its new paths', async () => {
|
||
await startStub(200, PNG_BYTES);
|
||
const { itemId, imageId } = await seedDraftWithImage();
|
||
|
||
const res = await request(app).post(
|
||
`/api/admin/item-drafts/${itemId}/images/${imageId}/remove-background`
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.image_path).toBe('/uploads/original-cutout.png');
|
||
expect(res.body.original_image_path).toBe('/uploads/original.jpg');
|
||
});
|
||
|
||
it('puts the original back', async () => {
|
||
await startStub(200, PNG_BYTES);
|
||
const { itemId, imageId } = await seedDraftWithImage();
|
||
await request(app).post(
|
||
`/api/admin/item-drafts/${itemId}/images/${imageId}/remove-background`
|
||
);
|
||
|
||
const res = await request(app).post(
|
||
`/api/admin/item-drafts/${itemId}/images/${imageId}/restore-original`
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.image_path).toBe('/uploads/original.jpg');
|
||
expect(res.body.original_image_path).toBeNull();
|
||
});
|
||
|
||
// The precheck and restoreImageOriginal's own guard can disagree under a
|
||
// race. Whichever call loses, the answer must say "already done" rather than
|
||
// "something is wrong with this application".
|
||
it('does not answer 500 when two restores race', async () => {
|
||
await startStub(200, PNG_BYTES);
|
||
const { itemId, imageId } = await seedDraftWithImage();
|
||
await request(app).post(
|
||
`/api/admin/item-drafts/${itemId}/images/${imageId}/remove-background`
|
||
);
|
||
|
||
const results = await Promise.all([
|
||
request(app).post(`/api/admin/item-drafts/${itemId}/images/${imageId}/restore-original`),
|
||
request(app).post(`/api/admin/item-drafts/${itemId}/images/${imageId}/restore-original`)
|
||
]);
|
||
|
||
const statuses = results.map((r) => r.status).sort((a, b) => a - b);
|
||
expect(statuses).toEqual([200, 404]);
|
||
});
|
||
|
||
// The catch around restoreImageOriginal exists only for the race above, not
|
||
// for every failure. A different kind of throw — a dropped connection, a
|
||
// transient outage — must not be reinterpreted as "already restored"; it has
|
||
// to stay a loud 500 so it reaches the app-level error handler.
|
||
it('stays a 500, not a 404, when restoreImageOriginal fails for a reason other than the race', async () => {
|
||
await startStub(200, PNG_BYTES);
|
||
const { itemId, imageId } = await seedDraftWithImage();
|
||
await request(app).post(
|
||
`/api/admin/item-drafts/${itemId}/images/${imageId}/remove-background`
|
||
);
|
||
|
||
const spy = jest
|
||
.spyOn(backgroundRemoval, 'restoreImageOriginal')
|
||
.mockRejectedValueOnce(new Error('database is down'));
|
||
try {
|
||
const res = await request(app).post(
|
||
`/api/admin/item-drafts/${itemId}/images/${imageId}/restore-original`
|
||
);
|
||
expect(res.status).toBe(500);
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
});
|
||
|
||
// 502 rather than 500: the request was fine and the app is fine, and saying
|
||
// which of the two failed is what stops somebody searching the application
|
||
// logs for a fault that is not there.
|
||
it('answers 502 when the sidecar will not, and leaves the photo alone', async () => {
|
||
await startStub(500, 'boom');
|
||
const { itemId, imageId } = await seedDraftWithImage();
|
||
|
||
const res = await request(app).post(
|
||
`/api/admin/item-drafts/${itemId}/images/${imageId}/remove-background`
|
||
);
|
||
|
||
expect(res.status).toBe(502);
|
||
const { rows } = await pool.query<{ image_path: string }>(
|
||
`SELECT image_path FROM item_images WHERE id = $1`,
|
||
[imageId]
|
||
);
|
||
expect(rows[0]?.image_path).toBe('/uploads/original.jpg');
|
||
});
|
||
|
||
// MINOR 3 (#281 review): a failure that happens before the sidecar is ever
|
||
// contacted — here, the file the row points to is missing from the uploads
|
||
// volume — must not be reported as "the service did not answer". That sends
|
||
// the admin to retry a service that was never reached, and hides the real
|
||
// reason in the server log.
|
||
it('does not answer 502 when the photo cannot be read, even though a sidecar is configured', async () => {
|
||
await startStub(200, PNG_BYTES);
|
||
const { itemId, imageId } = await seedDraftWithImage();
|
||
await fsp.unlink(path.join(uploads, 'original.jpg'));
|
||
|
||
const res = await request(app).post(
|
||
`/api/admin/item-drafts/${itemId}/images/${imageId}/remove-background`
|
||
);
|
||
|
||
expect(res.status).not.toBe(502);
|
||
expect(res.body.error).not.toMatch(/did not answer/);
|
||
});
|
||
|
||
// Scoped by item as well as by image. The id is a serial, so guessing one is
|
||
// not hard, and a photo from another submission must not be reachable
|
||
// through this item's URL.
|
||
it('refuses an image that does not belong to the item', async () => {
|
||
await startStub(200, PNG_BYTES);
|
||
const first = await seedDraftWithImage();
|
||
const second = await seedDraftWithImage();
|
||
|
||
const res = await request(app).post(
|
||
`/api/admin/item-drafts/${first.itemId}/images/${second.imageId}/remove-background`
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it('refuses to restore a photo that was never cut out', async () => {
|
||
const { itemId, imageId } = await seedDraftWithImage();
|
||
|
||
const res = await request(app).post(
|
||
`/api/admin/item-drafts/${itemId}/images/${imageId}/restore-original`
|
||
);
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|