mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(passport-photo): require the face-detection bundle, not just background-removal (#329)
* fix(passport-photo): require the face-detection bundle, not just background-removal Passport Photo runs face-landmark detection (face_landmarks.py, gated to the face-detection bundle) before background removal (background-removal bundle), but it was only declared under and guarded against background-removal. A user who installed only Background Removal passed every JS-side check, then hit a late "feature_not_installed" from the Python dispatcher gate when the analyze step ran face landmarks, and the UI never told them Face Detection was needed. - shared: add TOOL_EXTRA_BUNDLES + getRequiredBundlesForTool so a tool can declare more than one required bundle (passport-photo needs background-removal and face-detection). enablesTools is untouched, so the one-tool-per-bundle invariant still holds. - api: isToolInstalled() now checks every required bundle; add getFirstMissingBundleForTool() so the analyze and base routes, pipeline (both guards) and batch report the bundle the user actually still needs. - web: the proactive install prompt (tool-page) and features-store treat a tool as installed only when all required bundles are present, and point the prompt at the first missing one (sequential install, no new UI). Refs #327 * test(passport-photo): deterministic integration coverage for the two-bundle guard Boots the real API with an isolated DATA_DIR and controls installed.json to prove the HTTP route behavior end-to-end: - nothing installed -> 501 naming background-removal - only background-removal installed -> 501 naming face-detection (issue #327) - both installed -> guard passes (not 501) - base route reports face-detection too Refs #327
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Deterministic integration tests for the passport-photo feature guard.
|
||||
*
|
||||
* passport-photo needs TWO bundles: background-removal (its primary) and
|
||||
* face-detection (for face-landmark detection). Installing only one must not
|
||||
* let the request through to a late "feature_not_installed" from the Python
|
||||
* dispatcher gate. This is the bug from issue #327.
|
||||
*
|
||||
* DATA_DIR is set to an isolated temp dir BEFORE importing feature-status (it
|
||||
* reads DATA_DIR at module load), so we can control exactly which bundles are
|
||||
* "installed" by writing installed.json. All app/feature-status imports are
|
||||
* dynamic so the env is set first.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
// ── Isolated DATA_DIR (must be set before any feature-status import) ──
|
||||
const testRoot = join(tmpdir(), `snapotter-passport-guard-${randomUUID()}`);
|
||||
const aiDir = join(testRoot, "ai");
|
||||
const installedPath = join(aiDir, "installed.json");
|
||||
|
||||
process.env.DATA_DIR = testRoot;
|
||||
process.env.FEATURE_MANIFEST_PATH = join(process.cwd(), "docker/feature-manifest.json");
|
||||
|
||||
mkdirSync(join(aiDir, "models"), { recursive: true });
|
||||
writeFileSync(installedPath, JSON.stringify({ bundles: {} }), "utf-8");
|
||||
|
||||
// ── Dynamic imports (after env is set) ───────────────────────────────
|
||||
const { invalidateCache } = await import("../../../../apps/api/src/lib/feature-status.js");
|
||||
const { fixtures, readFixture } = await import("../../../fixtures/index.js");
|
||||
const { buildTestApp, createMultipartPayload, loginAsAdmin } = await import("../../test-server.js");
|
||||
|
||||
type TestAppType = Awaited<ReturnType<typeof buildTestApp>>;
|
||||
|
||||
const PNG = readFixture(fixtures.image.base.png200);
|
||||
|
||||
let testApp: TestAppType;
|
||||
let app: TestAppType["app"];
|
||||
let adminToken: string;
|
||||
|
||||
/** Overwrite installed.json so exactly the given bundles read as installed. */
|
||||
function setInstalled(bundleIds: string[]): void {
|
||||
const bundles: Record<string, { version: string; installedAt: string; models: string[] }> = {};
|
||||
for (const id of bundleIds) {
|
||||
bundles[id] = { version: "1.0.0-test", installedAt: "2026-01-01T00:00:00.000Z", models: [] };
|
||||
}
|
||||
writeFileSync(installedPath, JSON.stringify({ bundles }), "utf-8");
|
||||
invalidateCache();
|
||||
}
|
||||
|
||||
async function postAnalyze() {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
]);
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/passport-photo/analyze",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
rmSync(testRoot, { recursive: true, force: true });
|
||||
}, 10_000);
|
||||
|
||||
describe("passport-photo feature guard (#327)", () => {
|
||||
it("returns 501 naming the primary bundle when nothing is installed", async () => {
|
||||
setInstalled([]);
|
||||
const res = await postAnalyze();
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
expect(json.feature).toBe("background-removal");
|
||||
});
|
||||
|
||||
it("returns 501 naming face-detection when only background-removal is installed", async () => {
|
||||
// The exact reporter scenario: Background Removal installed, the analyze
|
||||
// step still needs Face Detection for face landmarks.
|
||||
setInstalled(["background-removal"]);
|
||||
const res = await postAnalyze();
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
expect(json.feature).toBe("face-detection");
|
||||
expect(json.featureName).toBe("Face Detection");
|
||||
});
|
||||
|
||||
it("does not 501 once both required bundles are installed", async () => {
|
||||
setInstalled(["background-removal", "face-detection"]);
|
||||
const res = await postAnalyze();
|
||||
// With both bundles marked installed the guard passes; the request then
|
||||
// succeeds (200) or fails downstream in the sidecar (422), but never 501.
|
||||
expect(res.statusCode).not.toBe(501);
|
||||
expect([200, 422]).toContain(res.statusCode);
|
||||
}, 60_000);
|
||||
|
||||
it("base route also reports face-detection when only background-removal is installed", async () => {
|
||||
setInstalled(["background-removal"]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/passport-photo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.feature).toBe("face-detection");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user