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 sharp from "sharp"; import { fileURLToPath } from "node:url"; import Fastify from "fastify"; import cookie from "@fastify/cookie"; import { openDb } from "../src/db.js"; import { loadConfig } from "../src/config.js"; import { registerRoutes } from "../src/routes.js"; import { createUser, login } from "../src/auth.js"; import { claimNextJob, getJob } from "../src/domain/jobs.js"; import { enqueueProductImport, runProductImport, fetchVendorVariantImages, ProductImportError } from "../src/domain/productImport.js"; import { getSourceImport } from "../src/domain/sourceImports.js"; import { getFact, getFacts, setFact } from "../src/domain/facts.js"; import { getProductBySlug } from "../src/domain/product.js"; import { upsertFabric, upsertShade, listVariants } from "../src/domain/fabric.js"; import type { FetchedSource } from "../src/media/sourceFetch.js"; const FIXTURE = fs.readFileSync( path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "bobochic-product.html"), "utf8", ); const URL = "https://bobochicparis.com/fr/canapes-d-angle-convertibles/18302-canape-d-angle-convertible-fuji-tissu-lisse.html#/7-couleur-beige/244-type_d_angle-angle_droit"; const FIXTURE_3ATTR = fs.readFileSync( path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "bobochic-product-3attr.html"), "utf8", ); // bez fragmentu #/... — jak realny link zaimportowany przez operatora (Everest) const URL_3ATTR_NO_FRAGMENT = "https://bobochicparis.com/fr/canapes-d-angle-convertibles/18789-canape-d-angle-l-convertible-coffre-everest-tissu-chine.html"; const fetched = (html: string, url: string): FetchedSource => ({ buffer: Buffer.from(html), mime: "text/html", finalUrl: url.split("#")[0]!, redirects: [], fetchedAt: "2026-09-01T00:00:00.000Z", }); async function fixture(t: { after: (fn: () => Promise) => void }) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-pimport-")); 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") }); 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" }); createUser(db, { login: "ap", password: "test-only", role: "approver", displayName: "Approver" }); const operator = db.prepare("SELECT id FROM users WHERE login='op'").get() as { id: string }; const cookies = { vilmal_session: login(db, "op", "test-only")!.sessionId }; const approverCookies = { vilmal_session: login(db, "ap", "test-only")!.sessionId }; return { db, app, cfg, cookies, approverCookies, actor: operator.id }; } async function runImport(db: Parameters[0], cfg: Parameters[1], jobId: string, html = FIXTURE) { claimNextJob(db); await runProductImport(db, cfg, jobId, async (url) => fetched(html, url)); } test("product import: URL → produkt + snapshot + fakty unverified, zero auto-confirm", async (t) => { const f = await fixture(t); const res = enqueueProductImport(f.db, { url: URL, idempotencyKey: "k1", actor: f.actor }); assert.equal(res.created, true); const job = getJob(f.db, res.import.job_id)!; assert.equal(job.type, "import_product"); assert.equal(job.status, "queued"); assert.equal(res.product?.slug, "canapes-d-angle-convertibles"); await runImport(f.db, f.cfg, job.id); assert.equal(getJob(f.db, job.id)?.status, "done"); const result = JSON.parse(getJob(f.db, job.id)!.result_json!) as { factsWritten: number; collection: string }; assert.equal(result.collection, "FUJI"); assert.ok(result.factsWritten > 40); const product = getProductBySlug(f.db, "canapes-d-angle-convertibles")!; assert.equal(product.name, "FUJI"); const facts = getFacts(f.db, product.id); assert.ok(facts.every((x) => x.status === "unverified")); assert.equal(getFact(f.db, product.id, "dimensions.width_cm")?.value, 292); assert.equal(getFact(f.db, product.id, "sleeping.width_cm")?.value, 154); assert.equal(getFact(f.db, product.id, "vendor.ean")?.value, "5905490067180"); assert.deepEqual(getFact(f.db, product.id, "sides.available")?.value, ["P", "L"].sort()); assert.equal(getFact(f.db, product.id, "vendor_fabric.martindale_cycles")?.value, 45000); assert.equal(getFact(f.db, product.id, "warranty.months")?.value, 24); assert.equal(getSourceImport(f.db, res.import.id)?.phase, "stored"); // Zero mediów/akceptacji — sam zapis faktów for (const table of ["assets", "approvals", "source_assets"]) { assert.equal((f.db.prepare(`SELECT COUNT(*) n FROM ${table}`).get() as { n: number }).n, 0); } }); test("product import: potwierdzone fakty nie są nadpisywane; warianty po fabricCode", async (t) => { const f = await fixture(t); const fabric = upsertFabric(f.db, { code: "ARENA", name: "Arena", producer: "SIC" }); upsertShade(f.db, fabric.id, { code: "01" }); upsertShade(f.db, fabric.id, { code: "02" }); const res = enqueueProductImport(f.db, { url: URL, name: "FUJI test", fabricCode: "ARENA", idempotencyKey: "k2", actor: f.actor }); const productId = res.import.product_id!; setFact(f.db, productId, { key: "dimensions.width_cm", value: 300, status: "confirmed", sourceKind: "measurement", sourceRef: "pomiar hali", changedBy: "test" }); await runImport(f.db, f.cfg, res.import.job_id); const result = JSON.parse(getJob(f.db, res.import.job_id)!.result_json!) as { skippedConfirmed: number; variantMatrix: { created: number } }; assert.equal(result.skippedConfirmed, 1); assert.equal(getFact(f.db, productId, "dimensions.width_cm")?.value, 300); // confirmed nietknięty assert.equal(getFact(f.db, productId, "dimensions.width_cm")?.status, "confirmed"); assert.equal(result.variantMatrix.created, 4); // 2 strony × 2 odcienie assert.equal(listVariants(f.db, productId).length, 4); }); test("product import: błąd parsowania/kategorii to error bez retry; brak fallbacku na slug", async (t) => { const f = await fixture(t); const empty = enqueueProductImport(f.db, { url: URL, idempotencyKey: "k3", actor: f.actor }); await runImport(f.db, f.cfg, empty.import.job_id, "bez danych"); assert.equal(getJob(f.db, empty.import.job_id)?.status, "error"); assert.match(getJob(f.db, empty.import.job_id)?.error ?? "", /brak|nie zawiera/i); assert.equal(getSourceImport(f.db, empty.import.id)?.phase, "failed"); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM product_facts").get() as { n: number }).n, 0); const wrongCat = enqueueProductImport(f.db, { url: "https://bobochicparis.com/fr/tapis/999-tapis.html", idempotencyKey: "k4", actor: f.actor }); await runImport(f.db, f.cfg, wrongCat.import.job_id); assert.equal(getJob(f.db, wrongCat.import.job_id)?.status, "error"); assert.match(getJob(f.db, wrongCat.import.job_id)?.error ?? "", /nietapicerowana/i); }); test("product import API: auth, idempotencja, walidacja hosta, ręczne tworzenie produktu", async (t) => { const f = await fixture(t); const payload = { url: URL, idempotencyKey: "api-1" }; assert.equal((await f.app.inject({ method: "POST", url: "/api/products/import", payload })).statusCode, 401); assert.equal((await f.app.inject({ method: "POST", url: "/api/products", cookies: f.cookies, payload: { name: "Ręczny" } })).statusCode, 201); assert.equal((await f.app.inject({ method: "POST", url: "/api/products", cookies: f.cookies, payload: { name: "Ręczny" } })).statusCode, 409); const bad = await f.app.inject({ method: "POST", url: "/api/products/import", cookies: f.cookies, payload: { ...payload, url: "https://evil.example.com/x" } }); assert.equal(bad.statusCode, 400); const res = await f.app.inject({ method: "POST", url: "/api/products/import", cookies: f.cookies, payload }); assert.equal(res.statusCode, 202); assert.equal(res.json().product.slug, "canapes-d-angle-convertibles"); const again = await f.app.inject({ method: "POST", url: "/api/products/import", cookies: f.cookies, payload }); assert.equal(again.statusCode, 200); assert.equal(again.json().import.id, res.json().import.id); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM products").get() as { n: number }).n, 2); }); test("product facts confirm: zbiorcze potwierdzenie tylko dla approvera, nieznane klucze = 404", async (t) => { const f = await fixture(t); const res = enqueueProductImport(f.db, { url: URL, idempotencyKey: "k5", actor: f.actor }); await runImport(f.db, f.cfg, res.import.job_id); const slug = getProductBySlug(f.db, "canapes-d-angle-convertibles")!.slug; const keys = ["dimensions.width_cm", "dimensions.depth_cm"]; assert.equal((await f.app.inject({ method: "POST", url: `/api/products/${slug}/facts/confirm`, cookies: f.cookies, payload: { keys } })).statusCode, 403); assert.equal((await f.app.inject({ method: "POST", url: `/api/products/${slug}/facts/confirm`, cookies: f.approverCookies, payload: { keys: ["nope.key"] } })).statusCode, 404); const ok = await f.app.inject({ method: "POST", url: `/api/products/${slug}/facts/confirm`, cookies: f.approverCookies, payload: { keys } }); assert.equal(ok.statusCode, 200); assert.equal(ok.json().confirmed, 2); const product = getProductBySlug(f.db, slug)!; assert.equal(getFact(f.db, product.id, "dimensions.width_cm")?.status, "confirmed"); assert.match(getFact(f.db, product.id, "dimensions.width_cm")?.sourceRef ?? "", /snapshot/); // pozostałe fakty pozostają unverified assert.equal(getFact(f.db, product.id, "vendor_fabric.type")?.status, "unverified"); }); const JPEG = (seed: number) => sharp({ create: { width: 32, height: 32, channels: 3, background: { r: 40 + (seed % 180), g: 70, b: 110 } } }).jpeg().toBuffer(); test("product import z fetchImages: galeria wariantu → source_assets + auto referencje ujęć", async (t) => { const f = await fixture(t); const res = enqueueProductImport(f.db, { url: URL, idempotencyKey: "img1", fetchImages: true, actor: f.actor }); claimNextJob(f.db); const seen: string[] = []; await runProductImport(f.db, f.cfg, res.import.job_id, async (url, kind) => { if (kind === "product_page") return fetched(FIXTURE, url); assert.equal(kind, "product_image"); seen.push(url); return { buffer: await JPEG(seen.length * 60), mime: "image/jpeg", finalUrl: url, redirects: [], fetchedAt: "2026-09-02T00:00:00.000Z" }; }); const job = getJob(f.db, res.import.job_id)!; assert.equal(job.status, "done"); const result = JSON.parse(job.result_json!) as { images: { found: number; stored: number; duplicates: number; warnings: string[]; refsAssigned?: string[] } }; assert.equal(result.images.found, 3); assert.equal(result.images.stored, 3); assert.equal(result.images.duplicates, 0); assert.deepEqual(result.images.warnings, []); // pobrano oryginały CDN rozstrzygniętego wariantu (nie innych kombinacji) assert.deepEqual(seen, ["287998", "287999", "288000"].map((id) => `https://cdn.bobochicparis.com/${id}/canape-d-angle-convertible-fuji-tissu-lisse.jpg`)); const assets = f.db.prepare("SELECT * FROM source_assets WHERE product_id = ? ORDER BY original_name").all(res.import.product_id) as Array<{ id: string; role: string; source_url: string | null; fetched_at: string | null; original_name: string }>; assert.equal(assets.length, 3); assert.ok(assets.every((a) => a.role === "vendor_reference" && a.source_url?.includes("cdn.bobochicparis.com") && a.fetched_at)); assert.match(assets[0]!.original_name, /fuji-\d+\.jpg/); // kolejność galerii vendora → kolejność ról planu ujęć (primary) assert.deepEqual(result.images.refsAssigned, ["hero", "packshot_front", "packshot_34"]); const refs = f.db.prepare( "SELECT shot_role, source_id, ref_kind FROM shot_refs WHERE product_id = ? ORDER BY created_at", ).all(res.import.product_id) as Array<{ shot_role: string; source_id: string; ref_kind: string }>; assert.deepEqual(refs.map((r) => r.shot_role), ["hero", "packshot_front", "packshot_34"]); assert.ok(refs.every((r) => r.ref_kind === "primary" && assets.some((a) => a.id === r.source_id))); // ponowny przebieg (retry joba) jest idempotentny — ingest deduplikuje // po bajtach, a przypisania ujęć nie tworzą nowych wierszy f.db.prepare("UPDATE jobs SET status = 'running' WHERE id = ?").run(res.import.job_id); const seen2: string[] = []; await runProductImport(f.db, f.cfg, res.import.job_id, async (url, kind) => { if (kind === "product_page") return fetched(FIXTURE, url); seen2.push(url); return { buffer: await JPEG(seen2.length * 60), mime: "image/jpeg", finalUrl: url, redirects: [], fetchedAt: "2026-09-02T00:00:00.000Z" }; }); assert.equal( (f.db.prepare("SELECT COUNT(*) n FROM source_assets WHERE product_id = ?").get(res.import.product_id) as { n: number }).n, 3, ); assert.equal( (f.db.prepare("SELECT COUNT(*) n FROM shot_refs WHERE product_id = ?").get(res.import.product_id) as { n: number }).n, 3, ); }); test("product import z fetchImages: adres bez wariantu (>1 kolor) → ZERO zdjęć zapisanych, ambiguous + lista wariantów, nie zgadywanie", async (t) => { const f = await fixture(t); const res = enqueueProductImport(f.db, { url: URL_3ATTR_NO_FRAGMENT, idempotencyKey: "img-ambiguous", fetchImages: true, actor: f.actor }); claimNextJob(f.db); const seen: string[] = []; await runProductImport(f.db, f.cfg, res.import.job_id, async (url, kind) => { if (kind === "product_page") return fetched(FIXTURE_3ATTR, url); seen.push(url); return { buffer: await JPEG(seen.length * 60), mime: "image/jpeg", finalUrl: url, redirects: [], fetchedAt: "2026-09-02T00:00:00.000Z" }; }); const job = getJob(f.db, res.import.job_id)!; assert.equal(job.status, "done"); const result = JSON.parse(job.result_json!) as { images: { found: number; stored: number; warnings: string[]; ambiguous?: boolean; availableVariants?: Array<{ color: string; typeAngle: string; url: string }> }; factsWritten: number; }; // regresja zgłoszenia operatora: adres bez wariantu NIE MOŻE zapisać // żadnego zdjęcia (ani mieszanki kolorów, ani "pierwszego z brzegu") assert.equal(result.images.ambiguous, true); assert.equal(result.images.found, 0); assert.equal(result.images.stored, 0); assert.equal(seen.length, 0, "nie wywołano fetchera dla żadnego zdjęcia"); assert.ok(result.images.warnings.length > 0); assert.match(result.images.warnings[0]!, /NIE zostały pobrane/); assert.equal(result.images.availableVariants?.length, 3); assert.ok(result.images.availableVariants!.some((v) => v.color === "Taupe")); // fakty (niezależne od koloru) mimo to zapisane assert.ok(result.factsWritten > 0); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM source_assets WHERE product_id = ?").get(res.import.product_id) as { n: number }).n, 0); }); // --- Krok 1 UI: operator wybiera wizualnie wariant po ambiguous ------------ test("fetchVendorVariantImages: wybór konkretnego wariantu z listy dogrywa zdjęcia bez ponownego pobrania strony", async (t) => { const f = await fixture(t); const res = enqueueProductImport(f.db, { url: URL_3ATTR_NO_FRAGMENT, idempotencyKey: "pick1", fetchImages: true, actor: f.actor }); let pageFetches = 0; await runImport(f.db, f.cfg, res.import.job_id, FIXTURE_3ATTR); // fixture bez fragmentu → ambiguous, zero obrazów const ambiguous = JSON.parse(getJob(f.db, res.import.job_id)!.result_json!) as { images: { availableVariants?: Array<{ color: string; typeAngle: string; url: string }> }; }; const target = ambiguous.images.availableVariants!.find((v) => v.color === "Taupe")!; assert.ok(target); const result = await fetchVendorVariantImages( f.db, f.cfg, { productId: res.import.product_id!, variantUrl: target.url, actor: f.actor }, async (url, kind) => { if (kind !== "product_image") throw new Error(`nie powinno pobierać strony ponownie (kind=${kind})`); pageFetches++; return { buffer: await JPEG(pageFetches * 37), mime: "image/jpeg", finalUrl: url, redirects: [], fetchedAt: "2026-09-03T00:00:00.000Z" }; }, ); assert.equal(result.pendingFabricChange, false); assert.equal(result.found, 2); // combinationImages["60262"] (Taupe/droit) ma 2 zdjęcia w fixturze assert.equal(result.warnings.length, 0, JSON.stringify(result.warnings)); assert.equal(result.stored, 2); assert.equal(pageFetches, 2, "dwa zdjęcia wariantu Taupe pobrane, strona ani raz"); // zdjęcia wybranego wariantu od razu przypisane jako referencje ujęć assert.deepEqual(result.refsAssigned, ["hero", "packshot_front"]); assert.equal( (f.db.prepare("SELECT COUNT(*) n FROM shot_refs WHERE product_id = ?").get(res.import.product_id) as { n: number }).n, 2, ); const assets = f.db.prepare("SELECT note FROM source_assets WHERE product_id = ?").all(res.import.product_id) as Array<{ note: string }>; assert.equal(assets.length, 2); assert.ok(assets.every((a) => !a.note.includes("[fabric_pending_change]"))); }); test("fetchVendorVariantImages: noFabricMatch bierze geometrię pierwszego wariantu i oznacza zdjęcia do zmiany tkaniny", async (t) => { const f = await fixture(t); const res = enqueueProductImport(f.db, { url: URL_3ATTR_NO_FRAGMENT, idempotencyKey: "pick2", fetchImages: true, actor: f.actor }); await runImport(f.db, f.cfg, res.import.job_id, FIXTURE_3ATTR); const result = await fetchVendorVariantImages( f.db, f.cfg, { productId: res.import.product_id!, noFabricMatch: true, actor: f.actor }, async (url) => ({ buffer: await JPEG(url.length), mime: "image/jpeg", finalUrl: url, redirects: [], fetchedAt: "2026-09-03T00:00:00.000Z" }), ); assert.equal(result.pendingFabricChange, true); assert.ok(result.stored > 0); const assets = f.db.prepare("SELECT note FROM source_assets WHERE product_id = ?").all(res.import.product_id) as Array<{ note: string }>; assert.ok(assets.every((a) => a.note.includes("[fabric_pending_change]"))); }); test("fetchVendorVariantImages: bez zapisanego snapshotu produktu → 404, nie zgaduje", async (t) => { const f = await fixture(t); const { product } = await f.app.inject({ method: "POST", url: "/api/products", cookies: f.cookies, payload: { name: "Bez importu" } }).then((r) => r.json()); await assert.rejects( () => fetchVendorVariantImages(f.db, f.cfg, { productId: product.id, noFabricMatch: true, actor: f.actor }, async () => { throw new Error("nie powinno być wywołane"); }), (e: unknown) => e instanceof ProductImportError && e.statusCode === 404, ); }); test("product import z fetchImages: awaria oryginału → fallback thickbox; błąd zdjęcia = warning, nie fail", async (t) => { const f = await fixture(t); const res = enqueueProductImport(f.db, { url: URL, idempotencyKey: "img2", fetchImages: true, actor: f.actor }); claimNextJob(f.db); const seen: string[] = []; await runProductImport(f.db, f.cfg, res.import.job_id, async (url, kind) => { if (kind === "product_page") return fetched(FIXTURE, url); seen.push(url); if (url.includes("/287999/")) throw new Error("HTTP 404"); // oryginał pada → fallback thickbox if (url.includes("288000")) throw new Error("HTTP 500"); // obie formy padają → warning return { buffer: await JPEG(seen.length + 5), mime: "image/jpeg", finalUrl: url, redirects: [], fetchedAt: "2026-09-02T00:00:00.000Z" }; }); const job = getJob(f.db, res.import.job_id)!; assert.equal(job.status, "done"); const result = JSON.parse(job.result_json!) as { images: { found: number; stored: number; warnings: string[] } }; assert.equal(result.images.stored, 2); // 287998 oryginał + 287999 thickbox assert.equal(result.images.warnings.length, 1); assert.match(result.images.warnings[0]!, /288000|poz\. 3/); assert.ok(seen.includes("https://cdn.bobochicparis.com/287999-thickbox_default/canape-d-angle-convertible-fuji-tissu-lisse.jpg")); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM source_assets").get() as { n: number }).n, 2); }); test("product import: importImages=false zapisuje same fakty (zero zdjęć i referencji); flaga jest częścią fingerprintu", async (t) => { const f = await fixture(t); const res = enqueueProductImport(f.db, { url: URL, idempotencyKey: "img3", importImages: false, actor: f.actor }); claimNextJob(f.db); const seen: string[] = []; await runProductImport(f.db, f.cfg, res.import.job_id, async (url, kind) => { if (kind === "product_page") return fetched(FIXTURE, url); seen.push(url); return { buffer: await JPEG(seen.length * 60), mime: "image/jpeg", finalUrl: url, redirects: [], fetchedAt: "2026-09-02T00:00:00.000Z" }; }); const job = getJob(f.db, res.import.job_id); assert.equal(job?.status, "done"); assert.equal(seen.length, 0); // żadne zdjęcie galerii nie zostało pobrane assert.equal((f.db.prepare("SELECT COUNT(*) n FROM source_assets WHERE product_id = ?").get(res.import.product_id) as { n: number }).n, 0); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM shot_refs WHERE product_id = ?").get(res.import.product_id) as { n: number }).n, 0); const result = JSON.parse(job!.result_json!) as { images: unknown; imagesSkipped?: boolean; factsWritten: number }; assert.equal(result.images, null); assert.equal(result.imagesSkipped, true); assert.ok(result.factsWritten > 0); // fakty/wymiary/opisy zapisywane jak zwykle // deprecated alias fetchImages nadal działa przez API (zgodność wsteczna) const api = await f.app.inject({ method: "POST", url: "/api/products/import", cookies: f.cookies, payload: { url: URL, idempotencyKey: "img4", fetchImages: true } }); assert.equal(api.statusCode, 202); const bad = await f.app.inject({ method: "POST", url: "/api/products/import", cookies: f.cookies, payload: { url: URL, idempotencyKey: "img5", fetchImages: "tak" } }); assert.equal(bad.statusCode, 400); const again = await f.app.inject({ method: "POST", url: "/api/products/import", cookies: f.cookies, payload: { url: URL, idempotencyKey: "img4", fetchImages: true } }); assert.equal(again.statusCode, 200); const againNewFlag = await f.app.inject({ method: "POST", url: "/api/products/import", cookies: f.cookies, payload: { url: URL, idempotencyKey: "img4", importImages: true } }); assert.equal(againNewFlag.statusCode, 200); // flaga jest częścią fingerprintu — ten sam klucz z inną flagą = konflikt const conflict = await f.app.inject({ method: "POST", url: "/api/products/import", cookies: f.cookies, payload: { url: URL, idempotencyKey: "img4", importImages: false } }); assert.equal(conflict.statusCode, 409); }); test("product fabrics bind: matryca wariantów z wybranych stron, błędy 400/404", async (t) => { const f = await fixture(t); const fabric = upsertFabric(f.db, { code: "TILIA", name: "Tilia", producer: "Davis" }); upsertShade(f.db, fabric.id, { code: "01" }); const res = await f.app.inject({ method: "POST", url: "/api/products", cookies: f.cookies, payload: { name: "Bind test", slug: "bind-test" } }); assert.equal(res.statusCode, 201); assert.equal((await f.app.inject({ method: "POST", url: "/api/products/bind-test/fabrics", cookies: f.cookies, payload: { fabricCode: "MISSING", sides: ["L"] } })).statusCode, 404); assert.equal((await f.app.inject({ method: "POST", url: "/api/products/bind-test/fabrics", cookies: f.cookies, payload: { fabricCode: "TILIA" } })).statusCode, 400); const bind = await f.app.inject({ method: "POST", url: "/api/products/bind-test/fabrics", cookies: f.cookies, payload: { fabricCode: "TILIA", sides: ["L"] } }); assert.equal(bind.statusCode, 200); assert.equal(bind.json().created, 1); // powiązanie zakłada placeholder kotwicy recoloru — bez niego nie byłoby // wiersza do edycji w Specyfikacji technicznej const anchor = f.db.prepare("SELECT value_json, status, source_kind FROM product_facts WHERE key = 'fabric.photographed_shade'").get() as { value_json: string; status: string; source_kind: string } | undefined; assert.ok(anchor); assert.equal(anchor.status, "unknown"); assert.equal(JSON.parse(anchor.value_json), null); assert.equal(anchor.source_kind, "owner"); // idempotentne — ani warianty, ani placeholder nie są duplikowane/nadpisywane const again = await f.app.inject({ method: "POST", url: "/api/products/bind-test/fabrics", cookies: f.cookies, payload: { fabricCode: "TILIA", sides: ["L"] } }); assert.equal(again.json().created, 0); assert.equal(again.json().total, 1); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM product_facts WHERE key = 'fabric.photographed_shade'").get() as { n: number }).n, 1); }); test("kotwica odcienia: potwierdzenie bez numeru z metki — auto źródło; inne fakty nadal wymagają źródła", async (t) => { const f = await fixture(t); await f.app.inject({ method: "POST", url: "/api/products", cookies: f.cookies, payload: { name: "Kotwica", slug: "kotwica" } }); // operator nie może potwierdzać (rola) — bez zmian assert.equal((await f.app.inject({ method: "PUT", url: "/api/products/kotwica/facts/fabric.photographed_shade", cookies: f.cookies, payload: { value: "01", status: "confirmed" }, })).statusCode, 403); // approver: jeden klik — bez sourceRef i bez sourceKind → auto źródło const ok = await f.app.inject({ method: "PUT", url: "/api/products/kotwica/facts/fabric.photographed_shade", cookies: f.approverCookies, payload: { value: "01", status: "confirmed" }, }); assert.equal(ok.statusCode, 200); const fact = ok.json().fact; assert.equal(fact.status, "confirmed"); assert.equal(fact.sourceKind, "owner"); assert.match(fact.sourceRef, /katalogu/); // operator może zapisać jako niezweryfikowane — też bez źródła const unv = await f.app.inject({ method: "PUT", url: "/api/products/kotwica/facts/fabric.photographed_shade", cookies: f.cookies, payload: { value: "02", status: "unverified" }, }); assert.equal(unv.statusCode, 200); // pozostałe fakty nadal wymagają jawnego źródła przy potwierdzeniu const denied = await f.app.inject({ method: "PUT", url: "/api/products/kotwica/facts/dimensions.width_cm", cookies: f.approverCookies, payload: { value: 200, status: "confirmed" }, }); assert.equal(denied.statusCode, 422); });