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 { 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((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((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 { 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, failed: false }); }); 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); }); // remove-backgrounds has always had this case; restore-originals did not, // and the two handlers are copy-paste rather than a shared helper — nothing // would have caught them diverging. it('answers 404 for an item that does not exist', async () => { await startStub(answerWithPng); expect((await request(app).post('/api/admin/items/999999/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); }); });