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, type AppConfig } 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 { enqueueFabricImport, runFabricImport } from "../src/domain/fabricImport.js"; import { getSourceImport } from "../src/domain/sourceImports.js"; import { parseSicCollectionPage, SicParseError } from "../src/import/sic.js"; import type { FetchedSource } from "../src/media/sourceFetch.js"; // --- parser SIC: czyste HTML, zero sieci ------------------------------------- const PAGE_URL = "https://sic.com.pl/oferta/tkaniny/nova/"; const HTML = `

Kolekcja NOVA

karta `; test("sic parser: odcienie dominującej kolekcji, deduplikacja, karta PDF, odrzucenie cudzych hostów", () => { const parsed = parseSicCollectionPage(HTML, PAGE_URL); assert.equal(parsed.collection, "Nova"); assert.equal(parsed.producer, "SIC"); assert.equal(parsed.cardUrl, "https://sic.com.pl/wp-content/uploads/2024/05/nova-product-profile.pdf"); assert.deepEqual(parsed.shades.map((s) => s.code), ["01", "02", "23"]); assert.deepEqual(parsed.shades.map((s) => s.name), ["Ivory", "Beige", "Anthracite"]); assert.equal(parsed.shades[0]!.swatchUrl, "https://sic.com.pl/wp-content/uploads/2024/05/nova-1-Ivory.jpg"); // wariant -448x448 odfiltrowany; arena-1 to inna kolekcja assert.equal(parsed.shades.filter((s) => s.code === "02").length, 1); assert.throws(() => parseSicCollectionPage("nic", PAGE_URL), SicParseError); assert.throws(() => parseSicCollectionPage(HTML, "https://example.com/x/"), /sic\.com\.pl/); }); // --- job import_fabric ------------------------------------------------------- const JPEG = (seed: number) => sharp({ create: { width: 32, height: 32, channels: 3, background: { r: 60 + (seed % 180), g: 90, b: 80 } } }).jpeg().toBuffer(); const PDF = Buffer.from("%PDF-1.4\n1 0 obj<>endobj\ntrailer<>\n%%EOF\n"); async function fixture(t: { after: (fn: () => Promise) => void }) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-fimport-")); 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" }); 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 }; return { db, app, cfg, cookies, actor: operator.id }; } function fakeFetcher(): (url: string, kind: string, signal?: AbortSignal) => Promise { return async (url, kind) => { if (kind === "fabric_page") return { buffer: Buffer.from(HTML), mime: "text/html", finalUrl: url, redirects: [], fetchedAt: "2026-09-02T00:00:00.000Z" }; if (kind === "fabric_card") return { buffer: PDF, mime: "application/pdf", finalUrl: url, redirects: [], fetchedAt: "2026-09-02T00:00:00.000Z" }; return { buffer: await JPEG(url.length), mime: "image/jpeg", finalUrl: url, redirects: [], fetchedAt: "2026-09-02T00:00:00.000Z" }; }; } async function runImport(db: Parameters[0], cfg: AppConfig, jobId: string) { claimNextJob(db); await runFabricImport(db, cfg, jobId, fakeFetcher()); } test("fabric import: strona SIC → tkanina + odcienie unverified + próbki jako source_assets + karta PDF", async (t) => { const f = await fixture(t); const res = enqueueFabricImport(f.db, { url: PAGE_URL, idempotencyKey: "fk1", actor: f.actor }); assert.equal(res.created, true); assert.equal(getJob(f.db, res.import.job_id)?.type, "import_fabric"); await runImport(f.db, f.cfg, res.import.job_id); const job = getJob(f.db, res.import.job_id)!; assert.equal(job.status, "done"); const result = JSON.parse(job.result_json!) as { code: string; shadesParsed: number; swatchesStored: number; cardStored: boolean; warnings: string[] }; assert.equal(result.code, "nova"); assert.equal(result.shadesParsed, 3); assert.equal(result.swatchesStored, 3); assert.equal(result.cardStored, true); const fabric = f.db.prepare("SELECT * FROM fabrics WHERE code='nova'").get() as { id: string; name: string; producer: string; verification: string; card_path: string | null }; assert.equal(fabric.name, "NOVA"); assert.equal(fabric.producer, "SIC"); assert.equal(fabric.verification, "unverified"); assert.ok(fabric.card_path, "karta PDF podpięta jako card_path"); const shades = f.db.prepare("SELECT * FROM fabric_shades WHERE fabric_id=? ORDER BY code").all(fabric.id) as Array<{ code: string; name: string; verification: string; official_image_path: string | null }>; assert.equal(shades.length, 3); assert.ok(shades.every((s) => s.verification === "unverified")); assert.ok(shades.every((s) => s.official_image_path), "każdy odcień ma próbkę"); // provenance: próbki jako source_assets bez product_id, z URL źródła const assets = f.db.prepare("SELECT * FROM source_assets ORDER BY original_name").all() as Array<{ role: string; product_id: string | null; source_url: string | null; fetched_at: string | null }>; assert.equal(assets.length, 4); // 3 próbki + karta assert.ok(assets.every((a) => a.product_id === null && a.source_url?.includes("sic.com.pl") && a.fetched_at)); assert.equal(assets.filter((a) => a.role === "fabric_reference").length, 3); assert.equal(assets.filter((a) => a.role === "source_document").length, 1); // wiersz importu związany z tkaniną, snapshot utrwalony const row = getSourceImport(f.db, res.import.id)!; assert.equal(row.phase, "stored"); assert.equal(row.fabric_id, fabric.id); assert.ok(row.sha256); }); test("fabric import: idempotencja klucza i konflikt klucza z innymi danymi", async (t) => { const f = await fixture(t); const first = enqueueFabricImport(f.db, { url: PAGE_URL, idempotencyKey: "fk2", actor: f.actor }); const again = enqueueFabricImport(f.db, { url: PAGE_URL, idempotencyKey: "fk2", actor: f.actor }); assert.equal(again.created, false); assert.equal(again.import.id, first.import.id); assert.throws( () => enqueueFabricImport(f.db, { url: PAGE_URL, fabricCode: "inny", idempotencyKey: "fk2", actor: f.actor }), /idempotencji/ ); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM source_imports").get() as { n: number }).n, 1); }); test("fabric import: reimport nie obniża confirmed i nie nadpisuje parametrów", async (t) => { const f = await fixture(t); const res = enqueueFabricImport(f.db, { url: PAGE_URL, fabricCode: "nova", idempotencyKey: "fk3", actor: f.actor }); await runImport(f.db, f.cfg, res.import.job_id); const fabric = f.db.prepare("SELECT * FROM fabrics WHERE code='nova'").get() as { id: string }; // człowiek potwierdził część katalogu f.db.prepare("UPDATE fabrics SET verification='confirmed', params_json=?, card_path='curated-card.pdf' WHERE id=?").run(JSON.stringify({ grammage_g_m2: 420 }), fabric.id); f.db.prepare("UPDATE fabric_shades SET verification='confirmed' WHERE fabric_id=? AND code='01'").run(fabric.id); const confirmedShadeBefore = f.db.prepare("SELECT official_image_path FROM fabric_shades WHERE fabric_id=? AND code='01'").get(fabric.id) as { official_image_path: string }; const res2 = enqueueFabricImport(f.db, { url: PAGE_URL, fabricCode: "nova", idempotencyKey: "fk4", actor: f.actor }); await runImport(f.db, f.cfg, res2.import.job_id); const job2 = getJob(f.db, res2.import.job_id)!; assert.equal(job2.status, "done"); const result = JSON.parse(job2.result_json!) as { skippedConfirmed: number }; assert.equal(result.skippedConfirmed, 1); const fabricAfter = f.db.prepare("SELECT verification, params_json, card_path FROM fabrics WHERE id=?").get(fabric.id) as { verification: string; params_json: string; card_path: string }; assert.equal(fabricAfter.verification, "confirmed", "reimport nie obniża statusu tkaniny"); assert.equal(JSON.parse(fabricAfter.params_json).grammage_g_m2, 420, "reimport nie nadpisuje parametrów"); assert.equal(fabricAfter.card_path, "curated-card.pdf", "reimport nie podmienia karty potwierdzonej tkaniny"); const sh = f.db.prepare("SELECT code, verification, official_image_path FROM fabric_shades WHERE fabric_id=? ORDER BY code").all(fabric.id) as Array<{ code: string; verification: string; official_image_path: string }>; assert.equal(sh.find((s) => s.code === "01")!.verification, "confirmed"); assert.equal(sh.find((s) => s.code === "01")!.official_image_path, confirmedShadeBefore.official_image_path, "reimport nie podmienia próbki potwierdzonego odcienia"); assert.equal(sh.find((s) => s.code === "02")!.verification, "unverified"); }); test("fabric import: błąd parsera = error bez retry; API auth i walidacja hosta", async (t) => { const f = await fixture(t); const res = enqueueFabricImport(f.db, { url: PAGE_URL, idempotencyKey: "fk5", actor: f.actor }); claimNextJob(f.db); await runFabricImport(f.db, f.cfg, res.import.job_id, async (url, kind) => ({ buffer: Buffer.from("brak odcieni"), mime: "text/html", finalUrl: url, redirects: [], fetchedAt: "2026-09-02T00:00:00.000Z", })); const job = getJob(f.db, res.import.job_id)!; assert.equal(job.status, "error"); assert.match(job.error ?? "", /odcieni|konwencji SIC/i); assert.equal(getSourceImport(f.db, res.import.id)?.phase, "failed"); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM fabrics").get() as { n: number }).n, 0); // API: auth + allowlist hostów assert.equal((await f.app.inject({ method: "POST", url: "/api/fabrics/import", payload: { url: PAGE_URL, idempotencyKey: "a1" } })).statusCode, 401); const bad = await f.app.inject({ method: "POST", url: "/api/fabrics/import", cookies: f.cookies, payload: { url: "https://evil.example.com/x", idempotencyKey: "a2" } }); assert.equal(bad.statusCode, 400); const productHost = await f.app.inject({ method: "POST", url: "/api/fabrics/import", cookies: f.cookies, payload: { url: "https://bobochicparis.com/fr/x/1-p.html", idempotencyKey: "a3" } }); assert.equal(productHost.statusCode, 400, "host produktowy nie jest hostem tkanin"); const ok = await f.app.inject({ method: "POST", url: "/api/fabrics/import", cookies: f.cookies, payload: { url: PAGE_URL, fabricCode: "nova-2", idempotencyKey: "a4" } }); assert.equal(ok.statusCode, 202); assert.equal(ok.json().created, true); });