feat(admin): remove and restore a photo's background per image (#281)
Adds two admin-gated endpoints on the review queue router: POST /:itemId/images/:imageId/remove-background and POST /:itemId/images/:imageId/restore-original. Both run synchronously and reuse the same backgroundRemoval module the drafting worker uses, so a cut-out obtained either way is identical and either can be undone by Restore.
Ownership is scoped by item as well as by image (imageOfItem selects on id AND item_id), because the image id is a serial and guessing one is easy — a photo belonging to a different submission must not be reachable through another item's URL. A sidecar failure returns 502, not 500, and leaves the row untouched, since removeImageBackground only writes the row after the cut-out file already exists on disk.
GET /api/admin/item-drafts now returns { drafts, backgroundRemoval } instead of { drafts }, and each image in the payload gains original_image_path, which is what the review queue UI will use to decide between "Remove background" and "Restore original". DRAFT_SELECT's images aggregate is extended accordingly, keeping the deliberate column spelling that guards against the upload_links token digest leaking into the response.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,11 @@ 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';
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
@@ -304,3 +309,144 @@ describe('the other three actions', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
/** 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-'));
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
/** 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();
|
||||
});
|
||||
|
||||
// 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');
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user