import { test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import crypto from "node:crypto"; import sharp from "sharp"; import { openDb } from "../src/db.js"; import type { AppConfig } from "../src/config.js"; import { createProduct } from "../src/domain/product.js"; import { setFact } from "../src/domain/facts.js"; import { upsertFabric, upsertShade, ensureVariantMatrix } from "../src/domain/fabric.js"; import { enqueueJob, getJob } from "../src/domain/jobs.js"; import { resolveProviderImages, startWorker } from "../src/worker.js"; import type { ImageProvider } from "../src/ai/provider.js"; async function fixture() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-provider-refs-")); const db = openDb(":memory:"); const cfg = { dataDir: dir, mediaDir: path.join(dir, "media"), originalsDir: path.join(dir, "originals"), derivedDir: path.join(dir, "derived"), env: "test", runcomfyApiKey: null, groqApiKey: null, } as AppConfig; for (const d of [cfg.mediaDir, cfg.originalsDir, cfg.derivedDir]) fs.mkdirSync(d); const cleanup = () => { db.close(); fs.rmSync(dir, { recursive: true, force: true }); }; return { db, cfg, cleanup }; } async function seedBigSource( f: Awaited>, id = "src_big", productId: string | null = null ) { // nieskompresowalny szum 3000×2000 → PNG ~18 MB; w base64 sam wywołałby 413 const big = await sharp(crypto.randomBytes(3000 * 2000 * 3), { raw: { width: 3000, height: 2000, channels: 3 }, }) .png() .toBuffer(); const sha = crypto.createHash("sha256").update(big).digest("hex"); fs.writeFileSync(path.join(f.cfg.originalsDir, `${id}.png`), big); f.db .prepare( "INSERT INTO source_assets(id,product_id,role,sha256,mime,bytes,original_name,path,created_at) VALUES(?,?,?,?,?,?,?,?,?)" ) .run(id, productId, "product_reference", sha, "image/png", big.length, `${id}.png`, `${id}.png`, "now"); return big.length; } test("referencje providera: 18 MB oryginał schodzi pod budżet, JPEG bez metadanych", async (t) => { const f = await fixture(); t.after(f.cleanup); const rawBytes = await seedBigSource(f); const { pairs, dropped } = await resolveProviderImages(f.db, f.cfg, ["src_big"], 16); assert.equal(pairs.length, 1); assert.equal(dropped.length, 0); assert.ok(pairs[0]![1].startsWith("data:image/jpeg;base64,")); const buf = Buffer.from(pairs[0]![1].split(",")[1]!, "base64"); assert.ok(buf.length < rawBytes / 20, `rekompresja za słaba: ${buf.length} vs ${rawBytes}`); const meta = await sharp(buf).metadata(); assert.ok((meta.width ?? 0) <= 1568 && (meta.height ?? 0) <= 1568); }); test("budżet payloadu: degradacja jakości, potem odrzucanie od końca — primary zostaje", async (t) => { const f = await fixture(); t.after(f.cleanup); await seedBigSource(f, "src_a"); await seedBigSource(f, "src_b"); await seedBigSource(f, "src_c"); // pełny budżet: wszystkie przechodzą po rekompresji const full = await resolveProviderImages(f.db, f.cfg, ["src_a", "src_b", "src_c"], 16); assert.equal(full.pairs.length, 3); assert.ok( full.pairs.reduce((n, p) => n + p[1].length, 0) <= 8 * 1024 * 1024, "3 × ~18 MB oryginałów musi zejść pod 8 MiB" ); // ciasny budżet: odrzucane od końca, primary zostaje nawet ponad budżet const tight = await resolveProviderImages(f.db, f.cfg, ["src_a", "src_b", "src_c"], 16, 1); assert.equal(tight.pairs.length, 1); assert.equal(tight.pairs[0]![0], "src_a"); assert.deepEqual(tight.dropped.sort(), ["src_b", "src_c"]); }); test("blueprint composed: requestId widoku utrwalany przed błędem kolejnego", async (t) => { const f = await fixture(); t.after(f.cleanup); const p = createProduct(f.db, { slug: "oskar", name: "OSKAR", kind: "corner-sofa" }); // mały ref — composed wymaga min. 1 obrazu na widok (fallback resolveViewRefs) const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: { r: 90, g: 90, b: 90 } }, }) .png() .toBuffer(); const sha = crypto.createHash("sha256").update(png).digest("hex"); fs.writeFileSync(path.join(f.cfg.originalsDir, "ref.png"), png); f.db .prepare( "INSERT INTO source_assets(id,product_id,role,sha256,mime,bytes,original_name,path,created_at) VALUES(?,?,?,?,?,?,?,?,?)" ) .run("src_ref", p.id, "product_reference", sha, "image/png", png.length, "ref.png", "ref.png", "now"); let submits = 0; const provider: ImageProvider = { name: "test", capabilities: { imageEdit: true, imageToVideo: false, maxReferenceImages: 8 }, unitCostUsd: 0, async submitImageEdit() { submits++; if (submits === 1) return { requestId: "req-top" }; throw new Error("RunComfy HTTP 413: HTTP content length exceeded 10485760 bytes"); }, async poll() { return { status: "failed", error: "n/a" }; }, }; const job = enqueueJob(f.db, { type: "generate_blueprint", provider: "test", maxAttempts: 1, payload: { productId: p.id, role: "blueprint", blueprintMode: "composed", viewRefs: {} }, }); const worker = startWorker(f.db, f.cfg, provider); t.after(() => worker.stop()); const deadline = Date.now() + 10_000; while (Date.now() < deadline && getJob(f.db, job.id)!.status !== "error") { await new Promise((r) => setTimeout(r, 150)); } const done = getJob(f.db, job.id)!; assert.equal(done.status, "error"); assert.equal(submits, 2); // top zlecony OK, front 413 — i koniec (maxAttempts=1) const payload = JSON.parse(done.payload_json) as { viewRequestIds?: Record }; assert.equal(payload.viewRequestIds?.top, "req-top"); // opłacony widok nie zginął }); /** * Kaskada referencji tkaniny: sloty 2–3 wychodzą WYŁĄCZNIE z odcienia * kotwicy (fabric.photographed_shade) — bez cichego fallbacku na inny * odcień kolekcji. `anchor = null` → fakt nie istnieje. */ async function shotFixture( f: Awaited>, withPhoto: boolean, anchor: { value: string; status: "confirmed" | "unverified" } | null = { value: "01", status: "confirmed" } ) { const p = createProduct(f.db, { slug: "sof", name: "Sofa T", kind: "corner-sofa" }); const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: { r: 90, g: 90, b: 90 } }, }).png().toBuffer(); fs.writeFileSync(path.join(f.cfg.originalsDir, "ref.png"), png); const src = (id: string, role: string) => { // sha256 ma UNIQUE — każdy source dostaje własny skrót (treść ta sama) const sha = crypto.createHash("sha256").update(id).digest("hex"); f.db.prepare( "INSERT INTO source_assets(id,product_id,role,sha256,mime,bytes,original_name,path,created_at) VALUES(?,?,?,?,?,?,?,?,?)" ).run(id, p.id, role, sha, "image/png", png.length, `${id}.png`, "ref.png", "now"); }; src("src_primary", "product_reference"); src("src_swatch", "fabric_reference"); if (withPhoto) src("src_vilmax_photo", "fabric_reference"); const fabric = upsertFabric(f.db, { code: "t", name: "T", producer: "X", verification: "confirmed" }); upsertShade(f.db, fabric.id, { code: "01", officialImagePath: "src_swatch", photoPath: withPhoto ? "src_vilmax_photo" : null, verification: "confirmed", }); ensureVariantMatrix(f.db, { productId: p.id, fabricId: fabric.id, sides: ["P"], shadeCodes: ["01"] }); if (anchor) { setFact(f.db, p.id, { key: "fabric.photographed_shade", value: anchor.value, status: anchor.status, sourceKind: "owner", sourceRef: "test" }); } return p; } async function runShot(f: Awaited>, p: { id: string }) { let captured: { prompt?: string } = {}; const provider: ImageProvider = { name: "test", capabilities: { imageEdit: true, imageToVideo: false, maxReferenceImages: 8 }, unitCostUsd: 0, async submitImageEdit(req) { captured.prompt = req.prompt; return { requestId: "req-1" }; }, async poll() { return { status: "pending" }; }, }; const job = enqueueJob(f.db, { type: "generate_shot", provider: "test", maxAttempts: 1, payload: { productId: p.id, role: "packshot_front", sourceIds: ["src_primary"], primarySourceId: "src_primary" }, }); const worker = startWorker(f.db, f.cfg, provider); try { const deadline = Date.now() + 10_000; while (Date.now() < deadline && getJob(f.db, job.id)!.status !== "waiting") { await new Promise((r) => setTimeout(r, 100)); } } finally { await worker.stop(); } const done = getJob(f.db, job.id)!; assert.equal(done.status, "waiting", JSON.stringify(done)); return { captured, payload: JSON.parse(done.payload_json) as { resolvedPrompt?: string; resolvedRefIds?: string[]; refWarnings?: string[] } }; } test("referencja tkaniny: próbka producenta (slot 2) i Zdjęcie Vilmax (slot 3) jadą razem, w tej kolejności", async (t) => { const f = await fixture(); t.after(f.cleanup); const p = await shotFixture(f, true); const { captured, payload } = await runShot(f, p); assert.equal(payload.resolvedRefIds?.[0], "src_primary"); assert.equal(payload.resolvedRefIds?.[1], "src_swatch"); assert.equal(payload.resolvedRefIds?.[2], "src_vilmax_photo"); assert.match(captured.prompt!, /Image 2 is the manufacturer's flat swatch.*Image 3 shows the same fabric on furniture/); }); test("referencja tkaniny: brak Zdjęcia Vilmax → tylko próbka producenta (slot 2) + ostrzeżenie o slocie 3", async (t) => { const f = await fixture(); t.after(f.cleanup); const p = await shotFixture(f, false); const { captured, payload } = await runShot(f, p); assert.equal(payload.resolvedRefIds?.[1], "src_swatch"); assert.match(captured.prompt!, /Image 2 is the manufacturer's flat swatch and defines the upholstery/); assert.doesNotMatch(captured.prompt!, /Image 3/); assert.ok(payload.refWarnings?.some((w) => w.includes("Zdjęcia Vilmax"))); }); test("referencja tkaniny: odcień niepotwierdzony nadal kotwiczy sloty + jawne ostrzeżenie", async (t) => { const f = await fixture(); t.after(f.cleanup); const p = await shotFixture(f, true, { value: "01", status: "unverified" }); const { payload } = await runShot(f, p); assert.equal(payload.resolvedRefIds?.[1], "src_swatch"); assert.equal(payload.resolvedRefIds?.[2], "src_vilmax_photo"); assert.ok(payload.refWarnings?.some((w) => w.includes("nie jest potwierdzony"))); }); test("referencja tkaniny: kotwica poza kolekcją → puste sloty, ZERO cichego fallbacku na inny odcień", async (t) => { const f = await fixture(); t.after(f.cleanup); // wcześniej cicho podstawiano pierwszy odcień kolekcji — tkanina zmieniała // się bez wiedzy operatora (FUJI: Ivory zamiast fotografowanego Beige) const p = await shotFixture(f, true, { value: "99", status: "confirmed" }); const { captured, payload } = await runShot(f, p); assert.deepEqual(payload.resolvedRefIds, ["src_primary"]); assert.doesNotMatch(captured.prompt!, /Image 2/); // bez referencji tkaniny kadr niesie też tapicerkę — klauzula bez „only" assert.match(captured.prompt!, /camera angle and upholstery/); assert.ok(payload.refWarnings?.some((w) => w.includes("nie zawiera odcienia"))); }); test("referencja tkaniny: brak faktu kotwicy → puste sloty + ostrzeżenie (nie fallback)", async (t) => { const f = await fixture(); t.after(f.cleanup); const p = await shotFixture(f, true, null); const { payload } = await runShot(f, p); assert.deepEqual(payload.resolvedRefIds, ["src_primary"]); assert.ok(payload.refWarnings?.some((w) => w.includes("Nie wybrano odcienia"))); }); test("generowanie: kadr z tkaniną niedopasowaną [fabric_pending_change] + brak referencji → 422 przed płatnym zleceniem", async (t) => { const f = await fixture(); t.after(f.cleanup); const { default: Fastify } = await import("fastify"); const { default: cookie } = await import("@fastify/cookie"); const { registerRoutes } = await import("../src/routes.js"); const { createUser, login } = await import("../src/auth.js"); const app = Fastify(); t.after(() => app.close()); await app.register(cookie); registerRoutes(app, f.db, { ...f.cfg, runcomfyApiKey: "test-key" }); createUser(f.db, { login: "op", displayName: "Op", role: "operator", password: "test-only-password" }); const cookies = { vilmal_session: login(f.db, "op", "test-only-password")!.sessionId }; const p = await shotFixture(f, false, null); // tkanina powiązana, kotwicy brak const png = await sharp({ create: { width: 8, height: 8, channels: 3, background: "#888" } }).png().toBuffer(); const sha = crypto.createHash("sha256").update("flagged").digest("hex"); fs.writeFileSync(path.join(f.cfg.originalsDir, "flagged.png"), png); f.db .prepare("INSERT INTO source_assets(id,product_id,role,sha256,mime,bytes,original_name,path,note,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)") .run("src_flagged", p.id, "vendor_reference", sha, "image/png", png.length, "v.png", "flagged.png", "tył [fabric_pending_change]", "now"); const blocked = await app.inject({ method: "POST", url: "/api/products/sof/shots/generate", cookies, payload: { role: "back", sourceIds: ["src_flagged"] }, }); assert.equal(blocked.statusCode, 422, blocked.body); assert.equal(blocked.json().code, "fabric_refs_missing"); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM jobs").get() as { n: number }).n, 0); // po ustawieniu kotwicy na odcień z próbką to samo żądanie przechodzi setFact(f.db, p.id, { key: "fabric.photographed_shade", value: "01", status: "confirmed", sourceKind: "owner", sourceRef: "test" }); const ok = await app.inject({ method: "POST", url: "/api/products/sof/shots/generate", cookies, payload: { role: "back", sourceIds: ["src_flagged"] }, }); assert.equal(ok.statusCode, 200, ok.body); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM jobs").get() as { n: number }).n, 1); });