Files
redefined-designs/backend/tests/unit/rembgClient.test.ts
bermudalambandClaude Opus 5 dfd900aadd fix(admin): stop reporting every removeImageBackground failure as a sidecar failure (#281)
The remove-background route's catch block turned every throw from removeImageBackground into a 502 "the background-removal service did not answer". But removeImageBackground also throws for an unrecognised file extension (a legacy .jpeg), for a file missing from the uploads volume, and when REMBG_URL is not set at all — none of which involve contacting the sidecar. The admin was told to retry a service that was never reached, while the real reason existed only in the server log.

Added SidecarRequestError in rembgClient.ts, following the NoOriginalToRestoreError pattern already in backgroundRemoval.ts. It is thrown only for failures that happen after actually attempting to reach the sidecar: the fetch call itself throwing (now wrapped in a try/catch, covering unreachable and timed-out), a non-2xx response, or a response that is not a PNG. It is deliberately not thrown for "REMBG_URL is not set", since that path never attempts contact at all.

The remove-background handler now checks err instanceof SidecarRequestError before answering 502; everything else answers 500 with a message that says what actually went wrong.

Added a unit test pairing (rembgClient.test.ts) asserting the sidecar-contacted failures are SidecarRequestError and the unconfigured case is not, and an integration test (adminItemDrafts.integration.test.ts) proving a missing upload file answers something other than 502 with a message that does not claim the service did not answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:42:47 -05:00

147 lines
5.2 KiB
TypeScript

import http from 'http';
import { AddressInfo } from 'net';
import { isRembgConfigured, removeBackground, SidecarRequestError } from '../../src/intake/rembgClient';
/** A real PNG header, so the client's own signature check sees what it expects. */
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]);
interface Capture {
url: string;
body: string;
}
/**
* A stub sidecar on an ephemeral port.
*
* Port 0 rather than a fixed number: several ports in the 55000s are
* Hyper-V-reserved on the development machine and bind with EACCES, and a
* fixed port would also stop this suite running beside itself.
*
* A real HTTP server rather than a mocked `fetch`, because what is being
* checked is the shape of the request that reaches the wire — above all that
* `model=u2net` is in it.
*/
async function withStub(
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
run: (capture: Capture) => Promise<void>
): Promise<void> {
const capture: Capture = { url: '', body: '' };
const server = http.createServer((req, res) => {
capture.url = req.url ?? '';
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => {
capture.body = Buffer.concat(chunks).toString('latin1');
handler(req, res);
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
process.env.REMBG_URL = `http://127.0.0.1:${port}`;
try {
await run(capture);
} finally {
delete process.env.REMBG_URL;
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
function respondWithPng(_req: http.IncomingMessage, res: http.ServerResponse): void {
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(PNG);
}
describe('whether the sidecar is configured', () => {
it('is false when REMBG_URL is unset', () => {
delete process.env.REMBG_URL;
expect(isRembgConfigured()).toBe(false);
});
// A variable set to spaces is a configuration mistake, not a value — the
// same reading envValidation applies everywhere else.
it('is false when REMBG_URL is blank', () => {
process.env.REMBG_URL = ' ';
expect(isRembgConfigured()).toBe(false);
delete process.env.REMBG_URL;
});
it('is true when REMBG_URL is set', () => {
process.env.REMBG_URL = 'http://rembg-syn:7000';
expect(isRembgConfigured()).toBe(true);
delete process.env.REMBG_URL;
});
});
describe('asking the sidecar to remove a background', () => {
/**
* The single most important assertion in this file.
*
* The image's default model is `bria-rmbg`, licensed non-commercial, and it
* is selected by simply not naming a model. Nothing about the returned image
* would reveal it had been used, so this is the only place it can be caught.
*/
it('names u2net explicitly, because the default is licensed non-commercial', async () => {
await withStub(respondWithPng, async (capture) => {
await removeBackground(JPEG, 'image/jpeg');
expect(capture.body).toContain('name="model"');
expect(capture.body).toContain('u2net');
});
});
it('posts the file to /api/remove and returns the PNG it gets back', async () => {
await withStub(respondWithPng, async (capture) => {
const out = await removeBackground(JPEG, 'image/jpeg');
expect(capture.url).toBe('/api/remove');
expect(capture.body).toContain('name="file"');
expect(out.subarray(0, 8)).toEqual(PNG.subarray(0, 8));
});
});
it('rejects rather than returning bytes when the sidecar errors', async () => {
await withStub(
(_req, res) => {
res.writeHead(500);
res.end('boom');
},
async () => {
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/500/);
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toBeInstanceOf(
SidecarRequestError
);
}
);
});
// The failure that would otherwise write an HTML error page over a
// photograph. Checked with the same magic-byte helper the upload path uses,
// rather than by trusting the Content-Type the sidecar sent.
it('rejects a response that is not actually a PNG', async () => {
await withStub(
(_req, res) => {
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end('<html>not an image</html>');
},
async () => {
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/not a PNG/);
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toBeInstanceOf(
SidecarRequestError
);
}
);
});
// Deliberately not a SidecarRequestError (MINOR 3, #281 review): this
// failure happens before any attempt to contact the sidecar, and a caller
// has to be able to tell "never tried" apart from "tried and failed".
it('rejects when it is not configured at all, without it being a sidecar failure', async () => {
delete process.env.REMBG_URL;
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/REMBG_URL/);
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.not.toBeInstanceOf(
SidecarRequestError
);
});
});