fix(admin): narrow the restore-original catch to the race it exists for (#281)
Round 1 caught every throw from restoreImageOriginal() and reported it as a 404, on the theory that losing the concurrent-restore race is the only way that call fails. But the throw carried nothing to distinguish that race from a genuinely different failure during the same UPDATE — a dropped database connection, a transient outage — so a real failure was now silently reinterpreted as "someone already restored this" instead of surfacing as the loud 500 it was before. backend/src/intake/backgroundRemoval.ts now exports NoOriginalToRestoreError, a named subclass of Error thrown in place of the bare Error restoreImageOriginal previously threw. The message text is unchanged, so backgroundRemoval.integration.test.ts's rejects.toThrow(/no original/) assertion keeps passing without modification. backend/src/routes/adminItemDrafts.ts catches that class specifically in the restore-original handler and rethrows anything else, so a real failure still reaches the app-level error handler and comes back as a 500 instead of being mislabeled as "already done". backend/tests/integration/adminItemDrafts.integration.test.ts adds a test that spies on restoreImageOriginal via jest.spyOn on the module namespace (the project compiles to CommonJS, so the route's call site reads the export off that object at call time, which makes the spy effective without jest.mock) to reject once with a plain Error, and asserts the response is 500 rather than 404 — proving the narrowing changes real behavior, not just internal structure. The spy is restored in a finally block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ 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();
|
||||
@@ -425,6 +426,30 @@ describe('the review queue’s background-removal control', () => {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user