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);
});