Files
redefined-designs/backend/tests/integration/adminItemDrafts.integration.test.ts
T
bermudalambandClaude Opus 5 6f8a0db130
Linting / lint (pull_request) Successful in 2m43s
SonarQube Analysis / sonarqube (pull_request) Failing after 35m48s
test(integration): clean up background-removal test-harness leftovers (#281)
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>
2026-09-03 13:43:25 -05:00

524 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 queues 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);
});
});