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 Fastify from "fastify"; import cookie from "@fastify/cookie"; import { openDb, type Db } from "../src/db.js"; import { loadConfig, type AppConfig } from "../src/config.js"; import { registerRoutes } from "../src/routes.js"; import { createUser, login } from "../src/auth.js"; import { registerAsset, decide } from "../src/domain/assets.js"; import { finishJob } from "../src/domain/jobs.js"; import { buildRecolorRoleBatch, confirmMasterMask, masterMaskState, maskOverlayPng, prepareRecolorRole, recolorReadiness, renderVariantRecolor, type RecolorBatchItemResult, type RenderedVariant, type VariantRowFull, } from "../src/domain/recolor.js"; const NOW = "2026-01-01T00:00:00Z"; const PID = "prd_t"; async function fixture(t: { after: (fn: () => Promise) => void }) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-vstudio-")); const cfg = loadConfig({ env: "test", dataDir: dir, dbPath: path.join(dir, "test.db"), mediaDir: path.join(dir, "media"), originalsDir: path.join(dir, "media/originals"), derivedDir: path.join(dir, "media/derived") }); fs.mkdirSync(cfg.originalsDir, { recursive: true }); fs.mkdirSync(cfg.derivedDir, { recursive: true }); const db = openDb(cfg.dbPath); const app = Fastify(); await app.register(cookie); registerRoutes(app, db, cfg); t.after(async () => { await app.close(); db.close(); fs.rmSync(dir, { recursive: true, force: true }); }); createUser(db, { login: "op", password: "test-only", role: "operator", displayName: "Operator" }); const cookies = { vilmal_session: login(db, "op", "test-only")!.sessionId }; return { db, app, cfg, cookies }; } /** Produkt + tkanina z 3 odcieniami (86 = kotwica) + warianty L; fakty potwierdzone. */ async function seed(db: Db, cfg: AppConfig, opts: { side?: boolean } = {}) { db.prepare("INSERT INTO products (id, slug, name, kind, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?)").run(PID, "t", "T", "x", "active", NOW, NOW); const facts: Array<[string, string]> = [["fabric.photographed_shade", '"86"']]; if (opts.side !== false) facts.push(["sides.photographed", '"P"']); for (const [key, val] of facts) { db.prepare("INSERT INTO product_facts (id, product_id, key, value_json, status, source_kind, created_at, updated_at) VALUES (?,?,?,?,'confirmed','owner',?,?)").run(`f_${key}`, PID, key, val, NOW, NOW); } db.prepare("INSERT INTO fabrics (id, code, name, producer, verification, created_at, updated_at) VALUES ('fab_t','tilia','TILIA','Davis','confirmed',?,?)").run(NOW, NOW); for (const [i, code] of ["86", "01", "62"].entries()) { const buf = await sharp({ create: { width: 16, height: 16, channels: 3, background: { r: 100 + i * 20, g: 100, b: 90 } } }).jpeg().toBuffer(); const sha = crypto.createHash("sha256").update(buf).digest("hex"); const rel = path.join("sw", `${sha}.jpg`); fs.mkdirSync(path.join(cfg.originalsDir, "sw"), { recursive: true }); fs.writeFileSync(path.join(cfg.originalsDir, rel), buf); db.prepare("INSERT INTO source_assets (id, product_id, role, sha256, mime, bytes, original_name, path, created_at) VALUES (?,?,'fabric_reference',?,'image/jpeg',?,?,?,?)").run(`src_${code}`, PID, sha, buf.length, `${code}.jpg`, rel, NOW); db.prepare("INSERT INTO fabric_shades (id, fabric_id, code, name, official_image_path, verification, created_at) VALUES (?,?,?,?,?,'confirmed',?)").run(`sh_${code}`, "fab_t", code, null, `src_${code}`, NOW); db.prepare("INSERT INTO variants (id, product_id, side, fabric_id, shade_code, status, created_at) VALUES (?,?,?,?,?,'draft',?)").run(`var_${code}`, PID, "L", "fab_t", code, NOW); } } async function addMaster(db: Db, cfg: AppConfig, opts: { approve?: boolean; role?: string } = {}) { const role = opts.role ?? "packshot_34"; const img = await sharp({ create: { width: 24, height: 16, channels: 3, background: { r: 90, g: 80, b: 70 } } }).jpeg().toBuffer(); const sha = crypto.createHash("sha256").update(img).digest("hex"); const rel = path.join("assets", sha.slice(0, 2), `${sha}.jpg`); const abs = path.join(cfg.mediaDir, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, img); const asset = registerAsset(db, { productId: PID, variantId: null, kind: "image", role: role as never, sha256: sha, mime: "image/jpeg", bytes: img.length, width: 24, height: 16, path: rel.replaceAll("\\", "/"), origin: "derived", }); if (opts.approve !== false) decide(db, { productId: PID, scope: "shot", target: role, assetId: asset.id, variantId: null, decision: "approved" }); return { asset, abs }; } function fakeRendered(variant: VariantRowFull): RenderedVariant { return { jpeg: Buffer.from(`jpeg-${variant.id}`), mirrored: false, report: { ok: true, output_path: "", mask_coverage: 0.3, backend: "test" }, tgtStats: { mean: [50, 0, 0], std: [1, 1, 1], count: 1 }, shade: { id: "s", code: variant.shade_code, name: null, official_image_path: "src_86", verification: "confirmed", fabric_name: "TILIA", fabric_verification: "confirmed" }, } as unknown as RenderedVariant; } test("batch: błąd jednej pozycji nie zabija pozostałych — wynik ma error/missing/asset", async (t) => { const f = await fixture(t); await seed(f.db, f.cfg); await addMaster(f.db, f.cfg); const results: Record = {}; const render: typeof renderVariantRecolor = async (_db, _cfg, _prep, variant) => { if (variant.id === "var_86") throw new Error("maska poza zakresem zaufania: coverage"); if (variant.id === "var_01") return { missing: ["odcień 01: brak próbki"] }; // zapis assetu wymaga realnego JPEG-a const r = fakeRendered(variant); r.jpeg = await sharp({ create: { width: 8, height: 8, channels: 3, background: { r: 200, g: 10, b: 10 } } }).jpeg().toBuffer(); return r; }; const out = await buildRecolorRoleBatch(f.db, f.cfg, { productId: PID, role: "packshot_34", variantIds: ["var_86", "var_01", "var_62"], render, onItem: (id, r) => { results[id] = r; }, }); assert.deepEqual(out, { prepared: true }); assert.match(results["var_86"]!.error!, /maska poza zakresem/); assert.deepEqual(results["var_01"]!.missing, ["odcień 01: brak próbki"]); assert.ok(results["var_62"]!.assetId, "trzecia pozycja przeszła mimo błędu pierwszej"); assert.equal(results["var_62"]!.status, "candidate"); }); test("render: strona L/P bez potwierdzonego sides.photographed jest blokerem (przed silnikiem)", async (t) => { const f = await fixture(t); await seed(f.db, f.cfg, { side: false }); await addMaster(f.db, f.cfg); const prep = await prepareRecolorRole(f.db, f.cfg, { productId: PID, role: "packshot_34" }); assert.ok(!("missing" in prep)); const variant = f.db.prepare("SELECT * FROM variants WHERE id = 'var_62'").get() as unknown as VariantRowFull; const out = await renderVariantRecolor(f.db, f.cfg, prep as never, variant); assert.ok("missing" in out && out.missing[0]!.includes("sides.photographed")); }); /** Wypieczona maska obok mastera (lewa połowa kadru = tapicerka) — jak sidecar z serwisu Pythona. */ async function bakeFakeMask(masterAbs: string) { const half = Buffer.alloc(24 * 16); for (let y = 0; y < 16; y++) for (let x = 0; x < 12; x++) half[y * 24 + x] = 255; await sharp(half, { raw: { width: 24, height: 16, channels: 1 } }).png().toFile(masterAbs.replace(/\.[^.]+$/, ".mask.png")); } test("readiness: powody i akcje, cykl maski brak → do sprawdzenia → zatwierdzona, nieaktualne media", async (t) => { const f = await fixture(t); await seed(f.db, f.cfg, { side: false }); let r = recolorReadiness(f.db, f.cfg, PID); const by = (k: string) => r.checks.find((c) => c.key === k)!; assert.equal(r.canGenerate, false); assert.equal(by("masters").ok, false); assert.equal(by("masters").action?.kind, "goto_master"); assert.equal(by("side").blocking, true); assert.equal(by("side").action?.kind, "set_side"); assert.equal(by("anchor").ok, true); assert.equal(by("shades").ok, true); f.db.prepare("INSERT INTO product_facts (id, product_id, key, value_json, status, source_kind, created_at, updated_at) VALUES ('f_side',?, 'sides.photographed','\"P\"','confirmed','owner',?,?)").run(PID, NOW, NOW); const master = await addMaster(f.db, f.cfg); // master jest, ale maska niewypieczona → blokada z akcją „wypiecz” r = recolorReadiness(f.db, f.cfg, PID); assert.equal(r.roles.find((x) => x.role === "packshot_34")!.mask, "missing"); assert.equal(by("masks").blocking, true); assert.equal(by("masks").action?.kind, "bake_mask"); assert.equal(r.canGenerate, false); // wypieczona, niezatwierdzona → „do sprawdzenia” await bakeFakeMask(master.abs); r = recolorReadiness(f.db, f.cfg, PID); assert.equal(r.roles.find((x) => x.role === "packshot_34")!.mask, "review"); assert.equal(by("masks").action?.kind, "review_mask"); assert.equal(r.canGenerate, false); // zatwierdzona → generowanie odblokowane assert.ok("maskKey" in confirmMasterMask(f.db, f.cfg, { productId: PID, role: "packshot_34", actor: "op" })); r = recolorReadiness(f.db, f.cfg, PID); assert.equal(r.roles.find((x) => x.role === "packshot_34")!.mask, "confirmed"); assert.equal(by("masks").ok, true); assert.equal(r.canGenerate, true); // wariant wygenerowany starym silnikiem → nieaktualny registerAsset(f.db, { productId: PID, variantId: "var_62", kind: "image", role: "packshot_34", sha256: crypto.createHash("sha256").update("stale").digest("hex"), mime: "image/jpeg", bytes: 1, width: 1, height: 1, path: "assets/xx/stale.jpg", origin: "derived", provenance: { method: "fabric_recolor_v7", recolor_engine: "fabric_recolor_v7", baseAsset: { id: master.asset.id }, anchorShade: "86" }, }); r = recolorReadiness(f.db, f.cfg, PID); assert.equal(r.stale.length, 1); assert.deepEqual(r.stale[0]!.reasons, ["starszy silnik"]); // niepotwierdzony odcień wskazany z kodem, ale nie blokuje reszty f.db.prepare("UPDATE fabric_shades SET verification = 'unverified' WHERE code = '01'").run(); r = recolorReadiness(f.db, f.cfg, PID); assert.equal(by("shades").blocking, false); assert.match(by("shades").reason!, /01/); assert.equal(r.shades.find((x) => x.code === "01")!.ok, false); }); test("zmiana mastera unieważnia maskę i oznacza warianty jako nieaktualne; przeliczona maska wymaga ponownego zatwierdzenia", async (t) => { const f = await fixture(t); await seed(f.db, f.cfg); const m1 = await addMaster(f.db, f.cfg); await bakeFakeMask(m1.abs); confirmMasterMask(f.db, f.cfg, { productId: PID, role: "packshot_34", actor: "op" }); assert.equal(masterMaskState(f.db, m1.abs, m1.asset.id), "confirmed"); registerAsset(f.db, { productId: PID, variantId: "var_62", kind: "image", role: "packshot_34", sha256: crypto.createHash("sha256").update("v-old").digest("hex"), mime: "image/jpeg", bytes: 1, width: 1, height: 1, path: "assets/xx/v-old.jpg", origin: "derived", provenance: { method: "fabric_recolor_v7", recolor_engine: "fabric_recolor_v8", baseAsset: { id: m1.asset.id }, anchorShade: "86" }, }); assert.equal(recolorReadiness(f.db, f.cfg, PID).stale.length, 0); // przeliczenie maski (inny plik) → wraca do „do sprawdzenia” await sharp(Buffer.alloc(24 * 16, 255), { raw: { width: 24, height: 16, channels: 1 } }).png().toFile(m1.abs.replace(/\.[^.]+$/, ".mask.png")); assert.equal(masterMaskState(f.db, m1.abs, m1.asset.id), "review"); confirmMasterMask(f.db, f.cfg, { productId: PID, role: "packshot_34", actor: "op" }); assert.equal(masterMaskState(f.db, m1.abs, m1.asset.id), "confirmed"); // nowy zatwierdzony master (inne piksele) → nowy asset bez maski, stare warianty nieaktualne const img2 = await sharp({ create: { width: 24, height: 16, channels: 3, background: { r: 10, g: 200, b: 30 } } }).jpeg().toBuffer(); const sha2 = crypto.createHash("sha256").update(img2).digest("hex"); const rel2 = path.join("assets", sha2.slice(0, 2), `${sha2}.jpg`); fs.mkdirSync(path.dirname(path.join(f.cfg.mediaDir, rel2)), { recursive: true }); fs.writeFileSync(path.join(f.cfg.mediaDir, rel2), img2); const m2 = registerAsset(f.db, { productId: PID, variantId: null, kind: "image", role: "packshot_34", sha256: sha2, mime: "image/jpeg", bytes: img2.length, width: 24, height: 16, path: rel2.replaceAll("\\", "/"), origin: "derived" }); decide(f.db, { productId: PID, scope: "shot", target: "packshot_34", assetId: m2.id, variantId: null, decision: "approved" }); const r = recolorReadiness(f.db, f.cfg, PID); const role = r.roles.find((x) => x.role === "packshot_34")!; assert.equal(role.masterVersion, m2.version); assert.equal(role.mask, "missing"); assert.equal(role.outdatedByMaster, 1); assert.deepEqual(r.stale[0]!.reasons, ["master zmieniony"]); assert.equal(r.canGenerate, false); }); test("bramka maski: prepareRecolorRole(requireMask) odmawia bez zatwierdzonej maski i puszcza po zatwierdzeniu", async (t) => { const f = await fixture(t); await seed(f.db, f.cfg); const m = await addMaster(f.db, f.cfg); const opts = { productId: PID, role: "packshot_34" as const, requireMask: true }; let prep = await prepareRecolorRole(f.db, f.cfg, opts); assert.ok("missing" in prep && /nie jest wypieczona/.test(prep.missing[0]!)); await bakeFakeMask(m.abs); prep = await prepareRecolorRole(f.db, f.cfg, opts); assert.ok("missing" in prep && /czeka na Twoje zatwierdzenie/.test(prep.missing[0]!)); confirmMasterMask(f.db, f.cfg, { productId: PID, role: "packshot_34", actor: "op" }); prep = await prepareRecolorRole(f.db, f.cfg, opts); assert.ok(!("missing" in prep)); // bez flagi (stare ścieżki/testy) bramka nie działa await sharp(Buffer.alloc(24 * 16, 200), { raw: { width: 24, height: 16, channels: 1 } }).png().toFile(m.abs.replace(/\.[^.]+$/, ".mask.png")); assert.ok(!("missing" in (await prepareRecolorRole(f.db, f.cfg, { productId: PID, role: "packshot_34" })))); assert.ok("missing" in (await prepareRecolorRole(f.db, f.cfg, opts))); }); test("nakładka maski: 409 bez maski, PNG z pokryciem po wypieczeniu sidecara", async (t) => { const f = await fixture(t); await seed(f.db, f.cfg); const m = await addMaster(f.db, f.cfg); const none = await maskOverlayPng(f.db, f.cfg, { productId: PID, role: "packshot_34" }); assert.ok("error" in none && none.code === 409); // lewa połowa kadru = tapicerka const half = Buffer.alloc(24 * 16); for (let y = 0; y < 16; y++) for (let x = 0; x < 12; x++) half[y * 24 + x] = 255; await sharp(half, { raw: { width: 24, height: 16, channels: 1 } }).png().toFile(m.abs.replace(/\.[^.]+$/, ".mask.png")); const out = await maskOverlayPng(f.db, f.cfg, { productId: PID, role: "packshot_34" }); assert.ok("png" in out); if ("png" in out) { assert.ok(Math.abs(out.coverage - 0.5) < 0.01, `coverage=${out.coverage}`); assert.equal((await sharp(out.png).metadata()).width, 24); } }); test("API: readiness wymaga sesji; wsad wymaga zatwierdzonej maski, nie dubluje aktywnego zadania; recolor-job odtwarza wynik z błędami", async (t) => { const f = await fixture(t); await seed(f.db, f.cfg); const m = await addMaster(f.db, f.cfg); assert.equal((await f.app.inject({ method: "GET", url: "/api/products/t/variants/readiness" })).statusCode, 401); const before = await f.app.inject({ method: "GET", url: "/api/products/t/variants/readiness", cookies: f.cookies }); assert.equal(before.statusCode, 200); assert.equal(before.json().canGenerate, false); const post = () => f.app.inject({ method: "POST", url: "/api/products/t/variants/recolor-batch", cookies: f.cookies, payload: { tasks: [{ variantId: "var_86", role: "packshot_34" }] } }); const blocked = await post(); assert.equal(blocked.statusCode, 422); assert.deepEqual(blocked.json().roles, ["packshot_34"]); await bakeFakeMask(m.abs); assert.equal((await f.app.inject({ method: "POST", url: "/api/products/t/masters/packshot_34/mask/confirm", cookies: f.cookies })).statusCode, 200); assert.equal((await f.app.inject({ method: "POST", url: "/api/products/t/masters/packshot_34/mask/confirm" })).statusCode, 401); const ready = await f.app.inject({ method: "GET", url: "/api/products/t/variants/readiness", cookies: f.cookies }); assert.equal(ready.json().canGenerate, true); const first = await post(); assert.equal(first.statusCode, 200); assert.equal(first.json().alreadyRunning, false); const second = await post(); assert.equal(second.json().alreadyRunning, true); assert.equal(second.json().job.id, first.json().job.id); // po zakończeniu z błędami pozycji: lista niepowodzeń z powodem, można zlecić nowy wsad finishJob(f.db, first.json().job.id, { ok: true, result: { done: 1, total: 1, created: 0, cached: 0, rejected: 0, failed: 1, failures: [{ variantId: "var_86", role: "packshot_34", reason: "maska poza zakresem" }], items: {} } }); const job = (await f.app.inject({ method: "GET", url: "/api/products/t/variants/recolor-job", cookies: f.cookies })).json().job; assert.equal(job.status, "done"); assert.equal(job.failed, 1); assert.equal(job.failures[0].reason, "maska poza zakresem"); const third = await post(); assert.equal(third.json().alreadyRunning, false); assert.notEqual(third.json().job.id, first.json().job.id); }); test("API: potwierdzenie maski bez wypieczenia → 409", async (t) => { const f = await fixture(t); await seed(f.db, f.cfg); await addMaster(f.db, f.cfg); assert.equal((await f.app.inject({ method: "POST", url: "/api/products/t/masters/packshot_34/mask/confirm", cookies: f.cookies })).statusCode, 409); }); test("API: maska mastera — zlecenie wypieczenia dedupowane, podgląd 409 bez maski, 422 bez mastera", async (t) => { const f = await fixture(t); await seed(f.db, f.cfg); const noMaster = await f.app.inject({ method: "POST", url: "/api/products/t/masters/hero/mask", cookies: f.cookies, payload: {} }); assert.equal(noMaster.statusCode, 422); await addMaster(f.db, f.cfg, { role: "hero" }); const a = await f.app.inject({ method: "POST", url: "/api/products/t/masters/hero/mask", cookies: f.cookies, payload: {} }); assert.equal(a.statusCode, 200); assert.equal(a.json().alreadyRunning, false); const b = await f.app.inject({ method: "POST", url: "/api/products/t/masters/hero/mask", cookies: f.cookies, payload: {} }); assert.equal(b.json().alreadyRunning, true); await addMaster(f.db, f.cfg, { role: "back" }); const other = await f.app.inject({ method: "POST", url: "/api/products/t/masters/back/mask", cookies: f.cookies, payload: {} }); assert.equal(other.json().alreadyRunning, false, "inna rola = osobne zadanie w kolejce"); const prev = await f.app.inject({ method: "GET", url: "/api/products/t/masters/hero/mask-preview", cookies: f.cookies }); assert.equal(prev.statusCode, 409); assert.equal((await f.app.inject({ method: "GET", url: "/api/products/t/masters/hero/mask-preview" })).statusCode, 401); });