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:
SnapOtter
2026-06-22 23:31:18 +08:00
committed by GitHub
parent 8952e9ba47
commit 32c1192d63
11 changed files with 346 additions and 43 deletions
@@ -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");
});
});
@@ -309,6 +309,39 @@ describe("Feature status queries", () => {
mod.markUninstalled("face-detection");
expect(mod.isToolInstalled("blur-faces")).toBe(false);
});
// passport-photo needs TWO bundles: background-removal (its primary) and
// face-detection (for face-landmark detection). Installing only one must not
// report the tool as ready. This is the bug behind issue #327.
it("isToolInstalled is false for passport-photo when only background-removal is installed", () => {
mod.markInstalled("background-removal", "1.0.0", []);
expect(mod.isToolInstalled("passport-photo")).toBe(false);
});
it("isToolInstalled is true for passport-photo only when both bundles are installed", () => {
mod.markInstalled("background-removal", "1.0.0", []);
mod.markInstalled("face-detection", "1.0.0", []);
expect(mod.isToolInstalled("passport-photo")).toBe(true);
});
it("getFirstMissingBundleForTool names face-detection when only background-removal is installed", () => {
mod.markInstalled("background-removal", "1.0.0", []);
expect(mod.getFirstMissingBundleForTool("passport-photo")).toBe("face-detection");
});
it("getFirstMissingBundleForTool returns the primary bundle first when nothing is installed", () => {
expect(mod.getFirstMissingBundleForTool("passport-photo")).toBe("background-removal");
});
it("getFirstMissingBundleForTool returns null when all required bundles are installed", () => {
mod.markInstalled("background-removal", "1.0.0", []);
mod.markInstalled("face-detection", "1.0.0", []);
expect(mod.getFirstMissingBundleForTool("passport-photo")).toBeNull();
});
it("getFirstMissingBundleForTool returns null for non-AI tools", () => {
expect(mod.getFirstMissingBundleForTool("resize")).toBeNull();
});
});
describe("Model verification via getFeatureStates", () => {
+50
View File
@@ -1,9 +1,11 @@
import {
FEATURE_BUNDLES,
getBundleForTool,
getRequiredBundlesForTool,
getToolsForBundle,
PYTHON_SIDECAR_TOOLS,
TOOL_BUNDLE_MAP,
TOOL_EXTRA_BUNDLES,
} from "@snapotter/shared";
import { describe, expect, it } from "vitest";
@@ -100,3 +102,51 @@ describe("Feature bundle edge cases", () => {
}
});
});
describe("getRequiredBundlesForTool", () => {
it("returns the primary bundle for a single-bundle tool", () => {
expect(getRequiredBundlesForTool("remove-background")).toEqual(["background-removal"]);
});
it("returns [] for non-AI tools", () => {
expect(getRequiredBundlesForTool("resize")).toEqual([]);
expect(getRequiredBundlesForTool("nonexistent-tool")).toEqual([]);
});
it("includes the primary bundle plus extras for cross-bundle tools", () => {
// Passport Photo runs face-landmark detection (face-detection) on top of
// background removal (its primary bundle).
expect(getRequiredBundlesForTool("passport-photo")).toEqual([
"background-removal",
"face-detection",
]);
});
it("lists the primary bundle first", () => {
for (const toolId of PYTHON_SIDECAR_TOOLS) {
const required = getRequiredBundlesForTool(toolId);
if (required.length > 0) {
expect(required[0]).toBe(TOOL_BUNDLE_MAP[toolId]);
}
}
});
it("never lists a bundle twice", () => {
for (const toolId of PYTHON_SIDECAR_TOOLS) {
const required = getRequiredBundlesForTool(toolId);
expect(new Set(required).size).toBe(required.length);
}
});
});
describe("TOOL_EXTRA_BUNDLES", () => {
it("references only real bundles and never the tool's own primary bundle", () => {
for (const [toolId, extras] of Object.entries(TOOL_EXTRA_BUNDLES)) {
const primary = TOOL_BUNDLE_MAP[toolId];
for (const bundleId of extras) {
expect(FEATURE_BUNDLES[bundleId], `Unknown extra bundle ${bundleId}`).toBeDefined();
expect(bundleId, `${toolId} lists its primary bundle as an extra`).not.toBe(primary);
}
}
});
});
+33
View File
@@ -197,6 +197,29 @@ describe("useFeaturesStore", () => {
expect(useFeaturesStore.getState().isToolInstalled("remove-background")).toBe(false);
});
// passport-photo needs background-removal AND face-detection (issue #327).
it("returns false for passport-photo when only background-removal is installed", () => {
useFeaturesStore.setState({
bundles: [
makeBundleState({ id: "background-removal", status: "installed" }),
makeBundleState({ id: "face-detection", status: "not_installed" }),
],
});
expect(useFeaturesStore.getState().isToolInstalled("passport-photo")).toBe(false);
});
it("returns true for passport-photo only when both required bundles are installed", () => {
useFeaturesStore.setState({
bundles: [
makeBundleState({ id: "background-removal", status: "installed" }),
makeBundleState({ id: "face-detection", status: "installed" }),
],
});
expect(useFeaturesStore.getState().isToolInstalled("passport-photo")).toBe(true);
});
});
describe("getBundleForTool()", () => {
@@ -221,6 +244,16 @@ describe("useFeaturesStore", () => {
const result = useFeaturesStore.getState().getBundleForTool("remove-background");
expect(result).toBeNull();
});
it("points at the first missing required bundle for a multi-bundle tool", () => {
const bg = makeBundleState({ id: "background-removal", status: "installed" });
const face = makeBundleState({ id: "face-detection", status: "not_installed" });
useFeaturesStore.setState({ bundles: [bg, face] });
// passport-photo needs both; background-removal is installed, so the
// prompt should ask for face-detection.
expect(useFeaturesStore.getState().getBundleForTool("passport-photo")).toEqual(face);
});
});
describe("clearError()", () => {