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:
@@ -18,7 +18,7 @@ import { dirname, join, resolve } from "node:path";
|
|||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { FeatureBundleState, FeatureStatus } from "@snapotter/shared";
|
import type { FeatureBundleState, FeatureStatus } from "@snapotter/shared";
|
||||||
import { FEATURE_BUNDLES, TOOL_BUNDLE_MAP } from "@snapotter/shared";
|
import { FEATURE_BUNDLES, getRequiredBundlesForTool } from "@snapotter/shared";
|
||||||
import * as tar from "tar";
|
import * as tar from "tar";
|
||||||
|
|
||||||
// ── Paths ───────────────────────────────────────────────────────────────
|
// ── Paths ───────────────────────────────────────────────────────────────
|
||||||
@@ -146,9 +146,22 @@ export function isFeatureInstalled(bundleId: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isToolInstalled(toolId: string): boolean {
|
export function isToolInstalled(toolId: string): boolean {
|
||||||
const bundleId = TOOL_BUNDLE_MAP[toolId];
|
const required = getRequiredBundlesForTool(toolId);
|
||||||
if (!bundleId) return true;
|
if (required.length === 0) return true;
|
||||||
return isFeatureInstalled(bundleId);
|
return required.every((bundleId) => isFeatureInstalled(bundleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first required bundle for a tool that is not yet installed, or null when
|
||||||
|
* the tool needs no bundle or all of them are installed. A tool can require
|
||||||
|
* more than one bundle (see TOOL_EXTRA_BUNDLES), so this is what tells the user
|
||||||
|
* exactly which feature to install next.
|
||||||
|
*/
|
||||||
|
export function getFirstMissingBundleForTool(toolId: string): string | null {
|
||||||
|
for (const bundleId of getRequiredBundlesForTool(toolId)) {
|
||||||
|
if (!isFeatureInstalled(bundleId)) return bundleId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Install status mutations ────────────────────────────────────────────
|
// ── Install status mutations ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { getBundleForTool, TOOL_BUNDLE_MAP, TOOLS, toolSection } from "@snapotter/shared";
|
import { FEATURE_BUNDLES, TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import archiver from "archiver";
|
import archiver from "archiver";
|
||||||
import type { FlowJob } from "bullmq";
|
import type { FlowJob } from "bullmq";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -26,7 +26,7 @@ import { type Pool, queueName, type ToolJobData } from "../jobs/types.js";
|
|||||||
import { autoOrient } from "../lib/auto-orient.js";
|
import { autoOrient } from "../lib/auto-orient.js";
|
||||||
import { getSecurityHeaders } from "../lib/csp.js";
|
import { getSecurityHeaders } from "../lib/csp.js";
|
||||||
import { formatZodErrors } from "../lib/errors.js";
|
import { formatZodErrors } from "../lib/errors.js";
|
||||||
import { isToolInstalled } from "../lib/feature-status.js";
|
import { getFirstMissingBundleForTool } from "../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||||
@@ -79,13 +79,14 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.status(404).send({ error: `Tool "${toolId}" not found` });
|
return reply.status(404).send({ error: `Tool "${toolId}" not found` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard: check if the tool's AI feature bundle is installed
|
// Guard: check that every AI feature bundle the tool needs is installed
|
||||||
if (!isToolInstalled(toolId)) {
|
const missingBundleId = getFirstMissingBundleForTool(toolId);
|
||||||
const bundle = getBundleForTool(toolId);
|
if (missingBundleId) {
|
||||||
|
const bundle = FEATURE_BUNDLES[missingBundleId];
|
||||||
return reply.status(501).send({
|
return reply.status(501).send({
|
||||||
error: "Feature not installed",
|
error: "Feature not installed",
|
||||||
code: "FEATURE_NOT_INSTALLED",
|
code: "FEATURE_NOT_INSTALLED",
|
||||||
feature: TOOL_BUNDLE_MAP[toolId],
|
feature: missingBundleId,
|
||||||
featureName: bundle?.name ?? toolId,
|
featureName: bundle?.name ?? toolId,
|
||||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
|
import { ANALYTICS_EVENTS, FEATURE_BUNDLES, TOOLS } from "@snapotter/shared";
|
||||||
import archiver from "archiver";
|
import archiver from "archiver";
|
||||||
import type { FlowJob } from "bullmq";
|
import type { FlowJob } from "bullmq";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -26,7 +26,7 @@ import { trackEvent } from "../lib/analytics.js";
|
|||||||
import { autoOrient } from "../lib/auto-orient.js";
|
import { autoOrient } from "../lib/auto-orient.js";
|
||||||
import { getSecurityHeaders } from "../lib/csp.js";
|
import { getSecurityHeaders } from "../lib/csp.js";
|
||||||
import { formatZodErrors } from "../lib/errors.js";
|
import { formatZodErrors } from "../lib/errors.js";
|
||||||
import { isToolInstalled } from "../lib/feature-status.js";
|
import { getFirstMissingBundleForTool } from "../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||||
@@ -361,12 +361,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Guard: check if the tool's AI feature bundle is installed
|
// Guard: check if the tool's AI feature bundle is installed
|
||||||
if (!isToolInstalled(resolvedToolId)) {
|
const missingBundleId = getFirstMissingBundleForTool(resolvedToolId);
|
||||||
const bundle = getBundleForTool(resolvedToolId);
|
if (missingBundleId) {
|
||||||
|
const bundle = FEATURE_BUNDLES[missingBundleId];
|
||||||
return reply.status(501).send({
|
return reply.status(501).send({
|
||||||
error: `Step ${i + 1} (${step.toolId}): Feature "${bundle?.name}" is not installed`,
|
error: `Step ${i + 1} (${step.toolId}): Feature "${bundle?.name}" is not installed`,
|
||||||
code: "FEATURE_NOT_INSTALLED",
|
code: "FEATURE_NOT_INSTALLED",
|
||||||
feature: TOOL_BUNDLE_MAP[resolvedToolId],
|
feature: missingBundleId,
|
||||||
featureName: bundle?.name ?? resolvedToolId,
|
featureName: bundle?.name ?? resolvedToolId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -787,12 +788,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isToolInstalled(resolvedToolId)) {
|
const missingBundleId = getFirstMissingBundleForTool(resolvedToolId);
|
||||||
const bundle = getBundleForTool(resolvedToolId);
|
if (missingBundleId) {
|
||||||
|
const bundle = FEATURE_BUNDLES[missingBundleId];
|
||||||
return reply.status(501).send({
|
return reply.status(501).send({
|
||||||
error: `Step ${i + 1} (${step.toolId}): Feature "${bundle?.name}" is not installed`,
|
error: `Step ${i + 1} (${step.toolId}): Feature "${bundle?.name}" is not installed`,
|
||||||
code: "FEATURE_NOT_INSTALLED",
|
code: "FEATURE_NOT_INSTALLED",
|
||||||
feature: TOOL_BUNDLE_MAP[resolvedToolId],
|
feature: missingBundleId,
|
||||||
featureName: bundle?.name ?? resolvedToolId,
|
featureName: bundle?.name ?? resolvedToolId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,13 @@ import { mkdir, rm } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { detectFaceLandmarks, removeBackground } from "@snapotter/ai";
|
import { detectFaceLandmarks, removeBackground } from "@snapotter/ai";
|
||||||
import {
|
import { FEATURE_BUNDLES, PASSPORT_SPECS, PRINT_LAYOUTS } from "@snapotter/shared";
|
||||||
getBundleForTool,
|
|
||||||
PASSPORT_SPECS,
|
|
||||||
PRINT_LAYOUTS,
|
|
||||||
TOOL_BUNDLE_MAP,
|
|
||||||
} from "@snapotter/shared";
|
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
import { getFirstMissingBundleForTool } from "../../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
@@ -134,12 +129,15 @@ export function registerPassportPhoto(app: FastifyInstance) {
|
|||||||
"/api/v1/tools/image/passport-photo/analyze",
|
"/api/v1/tools/image/passport-photo/analyze",
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const toolId = "passport-photo";
|
const toolId = "passport-photo";
|
||||||
if (!isToolInstalled(toolId)) {
|
// Passport Photo needs two bundles (face-detection + background-removal);
|
||||||
const bundle = getBundleForTool(toolId);
|
// report whichever one the user still has to install.
|
||||||
|
const missingBundleId = getFirstMissingBundleForTool(toolId);
|
||||||
|
if (missingBundleId) {
|
||||||
|
const bundle = FEATURE_BUNDLES[missingBundleId];
|
||||||
return reply.status(501).send({
|
return reply.status(501).send({
|
||||||
error: "Feature not installed",
|
error: "Feature not installed",
|
||||||
code: "FEATURE_NOT_INSTALLED",
|
code: "FEATURE_NOT_INSTALLED",
|
||||||
feature: TOOL_BUNDLE_MAP[toolId],
|
feature: missingBundleId,
|
||||||
featureName: bundle?.name ?? toolId,
|
featureName: bundle?.name ?? toolId,
|
||||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||||
});
|
});
|
||||||
@@ -313,12 +311,13 @@ export function registerPassportPhoto(app: FastifyInstance) {
|
|||||||
"/api/v1/tools/image/passport-photo",
|
"/api/v1/tools/image/passport-photo",
|
||||||
async (_request: FastifyRequest, reply: FastifyReply) => {
|
async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const toolId = "passport-photo";
|
const toolId = "passport-photo";
|
||||||
if (!isToolInstalled(toolId)) {
|
const missingBundleId = getFirstMissingBundleForTool(toolId);
|
||||||
const bundle = getBundleForTool(toolId);
|
if (missingBundleId) {
|
||||||
|
const bundle = FEATURE_BUNDLES[missingBundleId];
|
||||||
return reply.status(501).send({
|
return reply.status(501).send({
|
||||||
error: "Feature not installed",
|
error: "Feature not installed",
|
||||||
code: "FEATURE_NOT_INSTALLED",
|
code: "FEATURE_NOT_INSTALLED",
|
||||||
feature: TOOL_BUNDLE_MAP[toolId],
|
feature: missingBundleId,
|
||||||
featureName: bundle?.name ?? toolId,
|
featureName: bundle?.name ?? toolId,
|
||||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
|
getRequiredBundlesForTool,
|
||||||
PYTHON_SIDECAR_TOOLS,
|
PYTHON_SIDECAR_TOOLS,
|
||||||
SECTIONS,
|
SECTIONS,
|
||||||
TOOL_BUNDLE_MAP,
|
|
||||||
TOOLS,
|
TOOLS,
|
||||||
toolSection,
|
toolSection,
|
||||||
} from "@snapotter/shared";
|
} from "@snapotter/shared";
|
||||||
@@ -226,9 +226,17 @@ export function ToolPage() {
|
|||||||
const fetchFeatures = useFeaturesStore((s) => s.fetch);
|
const fetchFeatures = useFeaturesStore((s) => s.fetch);
|
||||||
const featureBundle = useMemo(() => {
|
const featureBundle = useMemo(() => {
|
||||||
if (!toolId) return null;
|
if (!toolId) return null;
|
||||||
const bundleId = TOOL_BUNDLE_MAP[toolId];
|
const required = getRequiredBundlesForTool(toolId);
|
||||||
if (!bundleId) return null;
|
if (required.length === 0) return null;
|
||||||
return featureBundles.find((b) => b.id === bundleId) ?? null;
|
// A tool can need more than one bundle (e.g. passport-photo needs
|
||||||
|
// background-removal AND face-detection). Surface the first one that is
|
||||||
|
// still missing so the install prompt asks for what's actually needed;
|
||||||
|
// once everything is installed, fall back to the primary bundle.
|
||||||
|
for (const bundleId of required) {
|
||||||
|
const bundle = featureBundles.find((b) => b.id === bundleId);
|
||||||
|
if (bundle && bundle.status !== "installed") return bundle;
|
||||||
|
}
|
||||||
|
return featureBundles.find((b) => b.id === required[0]) ?? null;
|
||||||
}, [toolId, featureBundles]);
|
}, [toolId, featureBundles]);
|
||||||
const toolInstalled = featureBundle ? featureBundle.status === "installed" : !isAiTool;
|
const toolInstalled = featureBundle ? featureBundle.status === "installed" : !isAiTool;
|
||||||
const showSizeComparison = toolId === "compress" || toolId === "optimize-for-web";
|
const showSizeComparison = toolId === "compress" || toolId === "optimize-for-web";
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import type { FeatureBundleState } from "@snapotter/shared";
|
import type { FeatureBundleState } from "@snapotter/shared";
|
||||||
import { TOOL_BUNDLE_MAP } from "@snapotter/shared";
|
import { TOOL_BUNDLE_MAP, TOOL_EXTRA_BUNDLES } from "@snapotter/shared";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { apiGet, apiPost } from "@/lib/api";
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every bundle a tool needs: its primary bundle plus any extras. Computed from
|
||||||
|
* the exported maps (not the shared helper) so unit tests that mock
|
||||||
|
* TOOL_BUNDLE_MAP still drive this logic. A tool can need more than one bundle
|
||||||
|
* (e.g. passport-photo needs background-removal AND face-detection).
|
||||||
|
*/
|
||||||
|
function requiredBundlesForTool(toolId: string): string[] {
|
||||||
|
const primary = TOOL_BUNDLE_MAP[toolId];
|
||||||
|
if (!primary) return [];
|
||||||
|
return [...new Set([primary, ...(TOOL_EXTRA_BUNDLES[toolId] ?? [])])];
|
||||||
|
}
|
||||||
|
|
||||||
interface BundleProgress {
|
interface BundleProgress {
|
||||||
percent: number;
|
percent: number;
|
||||||
stage: string;
|
stage: string;
|
||||||
@@ -197,16 +209,24 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
|||||||
refresh: refreshBundles,
|
refresh: refreshBundles,
|
||||||
|
|
||||||
isToolInstalled: (toolId: string) => {
|
isToolInstalled: (toolId: string) => {
|
||||||
const bundleId = TOOL_BUNDLE_MAP[toolId];
|
const required = requiredBundlesForTool(toolId);
|
||||||
if (!bundleId) return true;
|
if (required.length === 0) return true;
|
||||||
const bundle = get().bundles.find((b) => b.id === bundleId);
|
return required.every(
|
||||||
return bundle?.status === "installed";
|
(bundleId) => get().bundles.find((b) => b.id === bundleId)?.status === "installed",
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
getBundleForTool: (toolId: string) => {
|
getBundleForTool: (toolId: string) => {
|
||||||
const bundleId = TOOL_BUNDLE_MAP[toolId];
|
const required = requiredBundlesForTool(toolId);
|
||||||
if (!bundleId) return null;
|
if (required.length === 0) return null;
|
||||||
return get().bundles.find((b) => b.id === bundleId) ?? null;
|
const bundles = get().bundles;
|
||||||
|
// Point the user at the first bundle they still need to install; fall
|
||||||
|
// back to the primary bundle once everything required is installed.
|
||||||
|
for (const bundleId of required) {
|
||||||
|
const bundle = bundles.find((b) => b.id === bundleId);
|
||||||
|
if (bundle && bundle.status !== "installed") return bundle;
|
||||||
|
}
|
||||||
|
return bundles.find((b) => b.id === required[0]) ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
installBundle: async (bundleId: string) => {
|
installBundle: async (bundleId: string) => {
|
||||||
|
|||||||
@@ -93,3 +93,28 @@ export function getBundleForTool(toolId: string): FeatureBundleInfo | null {
|
|||||||
export function getToolsForBundle(bundleId: string): string[] {
|
export function getToolsForBundle(bundleId: string): string[] {
|
||||||
return FEATURE_BUNDLES[bundleId]?.enablesTools ?? [];
|
return FEATURE_BUNDLES[bundleId]?.enablesTools ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tools that need AI models from MORE THAN ONE feature bundle.
|
||||||
|
*
|
||||||
|
* The "primary" bundle (the one whose `enablesTools` lists the tool) is in
|
||||||
|
* TOOL_BUNDLE_MAP. Any ADDITIONAL bundles the tool's processing requires are
|
||||||
|
* listed here. Example: Passport Photo removes the background (its primary
|
||||||
|
* `background-removal` bundle) but first runs face-landmark detection, which
|
||||||
|
* is gated to the separate `face-detection` bundle.
|
||||||
|
*/
|
||||||
|
export const TOOL_EXTRA_BUNDLES: Record<string, string[]> = {
|
||||||
|
"passport-photo": ["face-detection"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every feature bundle a tool needs installed before it can run: its primary
|
||||||
|
* bundle plus any extras from TOOL_EXTRA_BUNDLES. Order is [primary, ...extras],
|
||||||
|
* deduped. Returns [] for tools that need no AI bundle.
|
||||||
|
*/
|
||||||
|
export function getRequiredBundlesForTool(toolId: string): string[] {
|
||||||
|
const primary = TOOL_BUNDLE_MAP[toolId];
|
||||||
|
if (!primary) return [];
|
||||||
|
const extras = TOOL_EXTRA_BUNDLES[toolId] ?? [];
|
||||||
|
return [...new Set([primary, ...extras])];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
mod.markUninstalled("face-detection");
|
||||||
expect(mod.isToolInstalled("blur-faces")).toBe(false);
|
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", () => {
|
describe("Model verification via getFeatureStates", () => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
FEATURE_BUNDLES,
|
FEATURE_BUNDLES,
|
||||||
getBundleForTool,
|
getBundleForTool,
|
||||||
|
getRequiredBundlesForTool,
|
||||||
getToolsForBundle,
|
getToolsForBundle,
|
||||||
PYTHON_SIDECAR_TOOLS,
|
PYTHON_SIDECAR_TOOLS,
|
||||||
TOOL_BUNDLE_MAP,
|
TOOL_BUNDLE_MAP,
|
||||||
|
TOOL_EXTRA_BUNDLES,
|
||||||
} from "@snapotter/shared";
|
} from "@snapotter/shared";
|
||||||
import { describe, expect, it } from "vitest";
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -197,6 +197,29 @@ describe("useFeaturesStore", () => {
|
|||||||
|
|
||||||
expect(useFeaturesStore.getState().isToolInstalled("remove-background")).toBe(false);
|
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()", () => {
|
describe("getBundleForTool()", () => {
|
||||||
@@ -221,6 +244,16 @@ describe("useFeaturesStore", () => {
|
|||||||
const result = useFeaturesStore.getState().getBundleForTool("remove-background");
|
const result = useFeaturesStore.getState().getBundleForTool("remove-background");
|
||||||
expect(result).toBeNull();
|
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()", () => {
|
describe("clearError()", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user