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 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 { createProduct } from "../src/domain/product.js"; import { upsertFabric, upsertShade, ensureVariantMatrix } from "../src/domain/fabric.js"; import { getFurnitureLeg } from "../src/domain/furnitureLegs.js"; import { setFact } from "../src/domain/facts.js"; import { enqueueJob, getJob } from "../src/domain/jobs.js"; import { importLegFromUrl } from "../src/domain/legImport.js"; import { parseMirtexLegPage, LegParseError, legPromptFromParsed } from "../src/import/legMirtex.js"; import { ingestUpload } from "../src/media/ingest.js"; import { normalizeSourceUrl, SourceFetchError, type FetchedSource } from "../src/media/sourceFetch.js"; import { startWorker, refToDataUri } from "../src/worker.js"; import { createTestProvider } from "../src/ai/testProvider.js"; const FIXTURE_HTML = fs.readFileSync(path.join(import.meta.dirname, "fixtures", "mirtex-leg.html"), "utf8"); const PAGE_URL = "https://www.mir-tex24.pl/nozki-drewniane/949-13209-nozka-meblowa-stozek-47x130.html"; async function fixture(t: { after: (fn: () => Promise) => void }) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-legimp-")); 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 }); }); const actor = 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, actor }; } const jpg = () => sharp({ create: { width: 32, height: 24, channels: 3, background: { r: 90, g: 60, b: 40 } } }).jpeg().toBuffer(); // --- parser mir-tex24 --------------------------------------------------------- test("mirtex parser: nazwa, wysokość ze specyfikacji/tytułu, materiał, EAN, główne zdjęcie", () => { const p = parseMirtexLegPage(FIXTURE_HTML, PAGE_URL); assert.equal(p.name, "Nóżka meblowa stożek 47x130"); assert.equal(p.material, "drewniane"); assert.equal(p.shape, "stożek"); assert.equal(p.ean, "5903211093661"); // tytuł "47x130" → ostatni wymiar = wysokość 130 mm → 13 cm assert.equal(p.heightCm, 13); assert.match(p.imageUrl, /^https:\/\/www\.mir-tex24\.pl\/.+nozka-meblowa-stozek-47x130\.jpg$/); assert.equal(legPromptFromParsed(p), "tapered cone wooden furniture legs, 13cm height"); }); test("mirtex parser: odrzuca inny host i stronę bez nazwy", () => { assert.throws(() => parseMirtexLegPage(FIXTURE_HTML, "https://example.com/x"), (e) => e instanceof LegParseError && e.code === "unsupported_host"); assert.throws(() => parseMirtexLegPage("", PAGE_URL), (e) => e instanceof LegParseError && e.code === "no_title"); }); test("sourceFetch: mir-tex24.pl w allowliście dla leg_page/leg_image, inne hosty odrzucane", () => { const ok = normalizeSourceUrl(PAGE_URL, "leg_page"); assert.equal(ok.fetchUrl, PAGE_URL); assert.equal(normalizeSourceUrl("https://www.mir-tex24.pl/img/1.jpg", "leg_image").fetchUrl, "https://www.mir-tex24.pl/img/1.jpg"); assert.throws(() => normalizeSourceUrl("https://evil.example/leg.html", "leg_page"), SourceFetchError); assert.throws(() => normalizeSourceUrl("http://mir-tex24.pl/x", "leg_page"), SourceFetchError); }); // --- importLegFromUrl ---------------------------------------------------------- test("import nóżki z mir-tex24: zdjęcie do source_assets (leg_reference), rekord z image_asset_id, idempotentny", async (t) => { const f = await fixture(t); const imgBuf = await jpg(); const fetcher = async (url: string, kind: string): Promise => ({ buffer: kind === "leg_page" ? Buffer.from(FIXTURE_HTML) : imgBuf, mime: kind === "leg_page" ? "text/html" : "image/jpeg", finalUrl: url, redirects: [], fetchedAt: "2026-09-05T00:00:00.000Z", }); const r1 = await importLegFromUrl(f.db, f.cfg, { url: PAGE_URL, actor: f.actor }, fetcher); assert.equal(r1.duplicate, false); const leg = r1.leg; assert.equal(leg.id, "leg_nozka-meblowa-stozek-47x130"); assert.equal(leg.name, "Nóżka meblowa stożek 47x130"); assert.equal(leg.height_cm, 13); assert.equal(leg.material, "drewniane"); assert.equal(leg.source_url, PAGE_URL); assert.ok(leg.image_asset_id); const src = f.db.prepare("SELECT * FROM source_assets WHERE id = ?").get(leg.image_asset_id!) as { role: string; sha256: string; path: string; source_url: string }; assert.equal(src.role, "leg_reference"); assert.match(src.sha256, /^[0-9a-f]{64}$/); assert.ok(fs.existsSync(path.join(f.cfg.originalsDir, src.path)), "plik próbki istnieje w originalsDir"); // reimport — ten sam rekord, dedup assetu po sha256 const r2 = await importLegFromUrl(f.db, f.cfg, { url: PAGE_URL, actor: f.actor }, fetcher); assert.equal(r2.duplicate, true); assert.equal(r2.leg.id, leg.id); assert.equal(r2.sourceId, r1.sourceId); const n = f.db.prepare("SELECT COUNT(*) c FROM furniture_legs").get() as { c: number }; assert.equal(n.c, 1); }); // --- trasy -------------------------------------------------------------------- test("POST /api/furniture-legs/import: sesja wymagana, walidacja hosta", async (t) => { const f = await fixture(t); assert.equal((await f.app.inject({ method: "POST", url: "/api/furniture-legs/import", payload: { url: PAGE_URL } })).statusCode, 401); const bad = await f.app.inject({ method: "POST", url: "/api/furniture-legs/import", cookies: f.cookies, payload: { url: "https://example.com/leg" } }); assert.equal(bad.statusCode, 400); }); test("POST /api/furniture-legs z imageAssetId: walidacja source_assets, zapis metadanych", async (t) => { const f = await fixture(t); const img = await ingestUpload(f.db, f.cfg, { buffer: await jpg(), originalName: "leg.jpg", productId: null, role: "leg_reference", note: "t", uploadedBy: f.actor, sourceUrl: null, fetchedAt: null, }); const missing = await f.app.inject({ method: "POST", url: "/api/furniture-legs", cookies: f.cookies, payload: { name: "X", finishType: "m", aiPrompt: "p", imageAssetId: "src_ghost" }, }); assert.equal(missing.statusCode, 404); const ok = await f.app.inject({ method: "POST", url: "/api/furniture-legs", cookies: f.cookies, payload: { name: "Buk 12", finishType: "drewno", aiPrompt: "beech legs", imageAssetId: img.id, heightCm: 12, material: "buk" }, }); assert.equal(ok.statusCode, 200); const leg = ok.json().leg; assert.equal(leg.image_asset_id, img.id); assert.equal(leg.height_cm, 12); assert.equal(leg.material, "buk"); }); // --- multi-image w workerze --------------------------------------------------- test("generate_shot: payload do providera ma kolejność mebel→tkanina→nóżka i prompt mapuje Image 1/2/3", async (t) => { const f = await fixture(t); const product = createProduct(f.db, { slug: "oskar-mi", name: "OSKAR", kind: "corner_sofa" }); const mkSource = async (name: string, role: "product_reference" | "fabric_reference" | "leg_reference", rgb: { r: number; g: number; b: number }) => ingestUpload(f.db, f.cfg, { buffer: await sharp({ create: { width: 16, height: 16, channels: 3, background: rgb } }).jpeg().toBuffer(), originalName: name, productId: product.id, role, note: name, uploadedBy: f.actor, sourceUrl: null, fetchedAt: null, }); const primary = await mkSource("primary.jpg", "product_reference", { r: 200, g: 200, b: 200 }); const swatch = await mkSource("swatch.jpg", "fabric_reference", { r: 120, g: 100, b: 80 }); const legImg = await mkSource("leg.jpg", "leg_reference", { r: 30, g: 30, b: 30 }); const fabric = upsertFabric(f.db, { code: "tilia", name: "TILIA", producer: "Davis" }); upsertShade(f.db, fabric.id, { code: "01", officialImagePath: swatch.id }); ensureVariantMatrix(f.db, { productId: product.id, fabricId: fabric.id, sides: ["L"] }); setFact(f.db, product.id, { key: "fabric.photographed_shade", value: "01", status: "confirmed", sourceKind: "owner", sourceRef: null, changedBy: f.actor }); f.db.prepare("INSERT INTO furniture_legs (id, name, ai_prompt, finish_type, image_asset_id, height_cm, material, source_url, created_at) VALUES (?,?,?,?,?,?,?,?,?)") .run("leg_cone_oak", "Stożek dąb", "oak tapered legs", "wood", legImg.id, 13, "drewniane", null, new Date().toISOString()); setFact(f.db, product.id, { key: "legs.furniture_leg_id", value: "leg_cone_oak", status: "confirmed", sourceKind: "owner", sourceRef: null, changedBy: f.actor }); const provider = createTestProvider(); const worker = startWorker(f.db, f.cfg, provider); t.after(async () => { await worker.stop(); }); const job = enqueueJob(f.db, { type: "generate_shot", payload: { productId: product.id, role: "packshot_front", sourceIds: [primary.id], primarySourceId: primary.id }, idempotencyKey: "mi-1" }); for (let i = 0; i < 100; i++) { const j = getJob(f.db, job.id)!; if (j.status === "done" || j.status === "failed") break; await new Promise((r) => setTimeout(r, 60)); } const done = getJob(f.db, job.id)!; assert.equal(done.status, "done", done.error ?? ""); const req = [...provider._requests.values()][0]!.req; assert.equal(req.images.length, 3); // kolejność data URI odpowiada primary → swatch → leg // (porównujemy do tych samych data URI, które zbudował worker — refToDataUri rekoduje obraz) assert.equal(req.images[0]!, await refToDataUri(f.db, f.cfg, primary.id)); assert.equal(req.images[1]!, await refToDataUri(f.db, f.cfg, swatch.id)); assert.equal(req.images[2]!, await refToDataUri(f.db, f.cfg, legImg.id)); assert.match(req.prompt, /Image 1 defines the exact furniture geometry/); assert.match(req.prompt, /Image 2 is the manufacturer's flat swatch/); assert.match(req.prompt, /Image 3 defines the exact legs/); assert.match(req.prompt, /RGB\(255, 255, 255\)/); // provenance: rozstrzygnięte referencje utrwalone w payloadzie joba const payload = JSON.parse(done.payload_json) as { resolvedRefIds?: string[] }; assert.deepEqual(payload.resolvedRefIds, [primary.id, swatch.id, legImg.id]); }); test("generate_shot: nóżka bez zdjęcia → ostrzeżenie i brak Image 3 w prompcie", async (t) => { const f = await fixture(t); const product = createProduct(f.db, { slug: "oskar-noleg", name: "OSKAR", kind: "corner_sofa" }); const primary = await ingestUpload(f.db, f.cfg, { buffer: await jpg(), originalName: "p.jpg", productId: product.id, role: "product_reference", note: "p", uploadedBy: f.actor, sourceUrl: null, fetchedAt: null, }); f.db.prepare("INSERT INTO furniture_legs (id, name, ai_prompt, finish_type, created_at) VALUES (?,?,?,?,?)") .run("leg_noimg", "Bez zdjęcia", "plain black legs", "metal", new Date().toISOString()); setFact(f.db, product.id, { key: "legs.furniture_leg_id", value: "leg_noimg", status: "confirmed", sourceKind: "owner", sourceRef: null, changedBy: f.actor }); const provider = createTestProvider(); const worker = startWorker(f.db, f.cfg, provider); t.after(async () => { await worker.stop(); }); const job = enqueueJob(f.db, { type: "generate_shot", payload: { productId: product.id, role: "packshot_front", sourceIds: [primary.id], primarySourceId: primary.id }, idempotencyKey: "mi-2" }); for (let i = 0; i < 100; i++) { const j = getJob(f.db, job.id)!; if (j.status === "done" || j.status === "failed") break; await new Promise((r) => setTimeout(r, 60)); } const done = getJob(f.db, job.id)!; assert.equal(done.status, "done", done.error ?? ""); const req = [...provider._requests.values()][0]!.req; assert.equal(req.images.length, 1); assert.doesNotMatch(req.prompt, /Image 3 defines/); assert.match(req.prompt, /Image 1 defines|product reference photo/); const result = JSON.parse(done.result_json ?? "{}") as { refWarnings?: string[] }; assert.ok(result.refWarnings?.some((w) => w.includes("leg_noimg"))); });