feat(admin): remove or restore every background on an item (#293)

Two routes on the item, and a small admin config route so the inventory screen can know whether to offer them.

Both answer 200 once the id is valid, even when the sidecar fails, and that is a deliberate departure from the per-photo endpoints in #281. Those act on one image, so the request either worked or it did not and 502 says which. These act on several, so "did it work" has no single answer — two of four is the normal shape of a bad day here, not an exception — and a 502 would throw away the count that is the only thing making the outcome actionable. Non-200 is reserved for not being able to try at all, which here means an unreadable or absent id.

No status check on either. A sold item's photos are still the shop's photos and improving them changes nothing about the sale; the guards on unpublish protect a checkout in progress and a completed sale, neither of which is at stake in a photograph's background.

The config route follows adminVersion's precedent rather than extending the public /api/config: admin-only, one purpose, and the reason written down. The inventory screen had no other way to learn the feature exists, because GET /api/admin/items answers a bare array with several consumers and reshaping it for one boolean is the worse trade.

Also extends the admin item select to carry original_image_path on each image, behind a new ADMIN_IMAGES_SUBQUERY kept separate from the shared IMAGES_SUBQUERY the public select uses. Task 3 needs to derive its restore-button label from that field, and the server never sent it for items before this — only the drafts endpoint carried it, added by #281 for the review queue. It stays admin-only for the same reason itemSelect.ts already names PUBLIC_ITEM_SELECT's columns explicitly: an internal original filename is nobody's business on the storefront, and sharing one subquery would put it in every public item response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 10:26:19 -05:00
co-authored by Claude Opus 5
parent 6f3e77fa88
commit 8f35204995
5 changed files with 312 additions and 2 deletions
@@ -0,0 +1,193 @@
import http from 'http';
import { AddressInfo } from 'net';
import { promises as fsp } from 'fs';
import os from 'os';
import path from 'path';
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
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 previousUploadsDir: string | undefined;
let stub: http.Server | null = null;
/**
* A stub sidecar on an ephemeral port, and a temporary uploads directory.
*
* Port 0 rather than a fixed number: several ports in the 55000s are
* Hyper-V-reserved on this machine. No test here contacts a real rembg — it
* takes forty seconds to start, and a suite depending on that is broken by
* construction.
*/
async function startStub(
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void
): Promise<void> {
uploads = await fsp.mkdtemp(path.join(os.tmpdir(), 'itembg-'));
previousUploadsDir = process.env.UPLOADS_DIR;
process.env.UPLOADS_DIR = uploads;
stub = http.createServer((req, res) => {
req.on('data', () => undefined);
req.on('end', () => handler(req, res));
});
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}`;
}
function answerWithPng(_req: http.IncomingMessage, res: http.ServerResponse): void {
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(PNG_BYTES);
}
beforeEach(async () => {
await resetDb();
});
afterEach(async () => {
delete process.env.REMBG_URL;
if (previousUploadsDir === undefined) delete process.env.UPLOADS_DIR;
else process.env.UPLOADS_DIR = previousUploadsDir;
if (uploads) await fsp.rm(uploads, { recursive: true, force: true });
uploads = '';
if (stub) {
await new Promise<void>((resolve) => stub!.close(() => resolve()));
stub = null;
}
});
afterAll(async () => {
await pool.end();
await closeDb();
});
/** An available item with two photos on disk. */
async function seedItem(): Promise<number> {
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO items (name, status) VALUES ('Blue vase', 'available') RETURNING id`
);
const itemId = rows[0]!.id;
for (const [index, name] of ['front.jpg', 'back.jpg'].entries()) {
await pool.query(
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`,
[itemId, `/uploads/${name}`, index]
);
await fsp.writeFile(path.join(uploads, name), JPEG_BYTES);
}
return itemId;
}
describe('removing every background on an item', () => {
it('answers with the summary', async () => {
await startStub(answerWithPng);
const itemId = await seedItem();
const res = await request(app).post(`/api/admin/items/${itemId}/remove-backgrounds`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ total: 2, removed: 2, failed: false });
});
// Every status, with no carve-out. A sold item's photos are still the shop's
// photos, and improving them changes nothing about the sale.
it.each(['sold', 'reserved', 'pending'])('works on a %s item', async (status) => {
await startStub(answerWithPng);
const itemId = await seedItem();
await pool.query(`UPDATE items SET status = $2 WHERE id = $1`, [itemId, status]);
const res = await request(app).post(`/api/admin/items/${itemId}/remove-backgrounds`);
expect(res.status).toBe(200);
expect(res.body.removed).toBe(2);
});
// The departure from #281's per-photo endpoints, and the reason for it: this
// acts on several images, so "did it work" has no single answer and a 502
// would throw away the count that makes the outcome actionable.
it('answers 200 with the count when the sidecar fails, not 502', async () => {
await startStub((_req, res) => {
res.writeHead(500);
res.end('boom');
});
const itemId = await seedItem();
const res = await request(app).post(`/api/admin/items/${itemId}/remove-backgrounds`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ total: 2, removed: 0, failed: true });
});
it('answers 404 for an id that cannot be read', async () => {
await startStub(answerWithPng);
expect((await request(app).post('/api/admin/items/abc/remove-backgrounds')).status).toBe(404);
});
it('answers 404 for an item that does not exist', async () => {
await startStub(answerWithPng);
expect((await request(app).post('/api/admin/items/999999/remove-backgrounds')).status).toBe(404);
});
});
describe('restoring every original on an item', () => {
it('puts them back and says how many', async () => {
await startStub(answerWithPng);
const itemId = await seedItem();
await request(app).post(`/api/admin/items/${itemId}/remove-backgrounds`);
const res = await request(app).post(`/api/admin/items/${itemId}/restore-originals`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ total: 2, restored: 2 });
});
it('answers 404 for an id that cannot be read', async () => {
await startStub(answerWithPng);
expect((await request(app).post('/api/admin/items/abc/restore-originals')).status).toBe(404);
});
});
// Extra scope beyond the endpoints themselves: Task 3 derives its button label
// from `original_image_path`, which the admin item select did not carry before
// this change. Asserted here because it is this task's select that changed.
describe('what item images carry in each list', () => {
it('gives original_image_path to admin, not the storefront', async () => {
await startStub(answerWithPng);
const itemId = await seedItem();
await request(app).post(`/api/admin/items/${itemId}/remove-backgrounds`);
const adminRes = await request(app).get('/api/admin/items');
expect(adminRes.status).toBe(200);
const adminItem = adminRes.body.find((item: { id: number }) => item.id === itemId);
expect(adminItem.images[0]).toHaveProperty('original_image_path', '/uploads/front.jpg');
const publicRes = await request(app).get('/api/items');
expect(publicRes.status).toBe(200);
const publicItem = publicRes.body.find((item: { id: number }) => item.id === itemId);
expect(publicItem.images[0]).not.toHaveProperty('original_image_path');
});
});
describe('what the admin screen is told about the feature', () => {
it('says it is on when a sidecar is configured', async () => {
process.env.REMBG_URL = 'http://rembg-syn:7000';
const res = await request(app).get('/api/admin/config');
expect(res.status).toBe(200);
expect(res.body.backgroundRemoval).toBe(true);
});
it('says it is off when none is', async () => {
delete process.env.REMBG_URL;
const res = await request(app).get('/api/admin/config');
expect(res.body.backgroundRemoval).toBe(false);
});
});