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 { createProduct } from "../src/domain/product.js"; import { upsertFabric, upsertShade, listVariants, listShades } from "../src/domain/fabric.js"; import { upsertFurnitureLeg } from "../src/domain/furnitureLegs.js"; import { addEans, eanForVariant } from "../src/domain/eanPool.js"; import { registerAsset } from "../src/domain/assets.js"; import { getFact } from "../src/domain/facts.js"; import { claimNextJob } from "../src/domain/jobs.js"; import { enqueueFabricImport, runFabricImport } from "../src/domain/fabricImport.js"; import type { FetchedSource } from "../src/media/sourceFetch.js"; async function fixture(t: { after: (fn: () => Promise) => void }) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-legs-")); 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 }; const product = createProduct(db, { slug: "sofa-test", name: "Sofa TEST", kind: "sofa" }); return { db, app, cfg, cookies, actor, product }; } // --- trasy furniture_legs ---------------------------------------------------- test("furniture legs: lista wymaga sesji, wybór ustawia fakt legs.furniture_leg_id, 404 dla nieznanych", async (t) => { const f = await fixture(t); upsertFurnitureLeg(f.db, { id: "leg_test_oak", name: "Dąb testowy", aiPrompt: "oak tapered legs", finishType: "wood" }); upsertFurnitureLeg(f.db, { id: "leg_test_black", name: "Czarny test", aiPrompt: "matte black legs", finishType: "metal" }); assert.equal((await f.app.inject({ method: "GET", url: "/api/furniture-legs" })).statusCode, 401); const list = await f.app.inject({ method: "GET", url: "/api/furniture-legs", cookies: f.cookies }); assert.equal(list.statusCode, 200); const legs = list.json().legs as Array<{ id: string; name: string }>; assert.equal(legs.length, 2); assert.ok(legs.some((l) => l.id === "leg_test_oak")); assert.equal((await f.app.inject({ method: "POST", url: "/api/products/sofa-test/legs", payload: { legId: "leg_test_oak" } })).statusCode, 401); assert.equal((await f.app.inject({ method: "POST", url: "/api/products/sofa-test/legs", cookies: f.cookies, payload: {} })).statusCode, 400); assert.equal((await f.app.inject({ method: "POST", url: "/api/products/nope/legs", cookies: f.cookies, payload: { legId: "leg_test_oak" } })).statusCode, 404); assert.equal((await f.app.inject({ method: "POST", url: "/api/products/sofa-test/legs", cookies: f.cookies, payload: { legId: "leg_ghost" } })).statusCode, 404); const res = await f.app.inject({ method: "POST", url: "/api/products/sofa-test/legs", cookies: f.cookies, payload: { legId: "leg_test_oak" } }); assert.equal(res.statusCode, 200); assert.equal(res.json().leg.id, "leg_test_oak"); const fact = getFact(f.db, f.product.id, "legs.furniture_leg_id"); assert.equal(fact?.value, "leg_test_oak"); assert.equal(fact?.status, "confirmed"); }); test("furniture legs: POST /api/furniture-legs tworzy rekord ze sluga z nazwy, waliduje i aktualizuje po id", async (t) => { const f = await fixture(t); assert.equal((await f.app.inject({ method: "POST", url: "/api/furniture-legs", payload: { name: "X", finishType: "y", aiPrompt: "z" } })).statusCode, 401); assert.equal((await f.app.inject({ method: "POST", url: "/api/furniture-legs", cookies: f.cookies, payload: { name: "Brzoza" } })).statusCode, 400); const res = await f.app.inject({ method: "POST", url: "/api/furniture-legs", cookies: f.cookies, payload: { name: "Buk Stożek 15", finishType: "drewno", aiPrompt: "beech tapered legs 15cm" }, }); assert.equal(res.statusCode, 200); const leg = res.json().leg; assert.equal(leg.id, "leg_buk-stozek-15"); assert.equal(leg.name, "Buk Stożek 15"); // jawnie podane id aktualizuje rekord (upsert) const upd = await f.app.inject({ method: "POST", url: "/api/furniture-legs", cookies: f.cookies, payload: { id: "leg_buk-stozek-15", name: "Buk Stożek 15 cm", finishType: "dąb", aiPrompt: "oak tapered legs 15cm" }, }); assert.equal(upd.statusCode, 200); assert.equal(upd.json().leg.name, "Buk Stożek 15 cm"); const all = await f.app.inject({ method: "GET", url: "/api/furniture-legs", cookies: f.cookies }); assert.equal((all.json().legs as unknown[]).length, 1); }); // --- pruneVariantSides przez trasę bind -------------------------------------- test("bind fabric z prune: szkice niepasujących stron usuwane, EAN wraca do puli, wariant z mediami zostaje", async (t) => { const f = await fixture(t); const fabric = upsertFabric(f.db, { code: "testcol", name: "TESTCOL", producer: "Test", verification: "unverified" }); for (const code of ["01", "02"]) upsertShade(f.db, fabric.id, { code, name: `Odcień ${code}` }); addEans(f.db, ["5909990000012", "5909990000029", "5909990000036", "5909990000043", "5909990000050", "5909990000067"]); // pierwsze wiązanie: L/P jak dla narożnika (4 warianty + EAN-y) const bind1 = await f.app.inject({ method: "POST", url: "/api/products/sofa-test/fabrics", cookies: f.cookies, payload: { fabricCode: "testcol", sides: ["L", "P"] } }); assert.equal(bind1.statusCode, 200); assert.equal(bind1.json().created, 4); let variants = listVariants(f.db, f.product.id); assert.equal(variants.length, 4); const eansBefore = variants.map((v) => v.ean).filter(Boolean); assert.equal(eansBefore.length, 4); // jeden wariant dostaje medium — nie może zostać usunięty przez prune const protectedVariant = variants.find((v) => v.side === "L")!; registerAsset(f.db, { productId: f.product.id, variantId: protectedVariant.id, kind: "image", role: "packshot_34", sha256: "a".repeat(64), mime: "image/jpeg", bytes: 10, path: "media/x.jpg", origin: "ai_generated", }); // zmiana koncepcji: to sofa — bind S z prune const bind2 = await f.app.inject({ method: "POST", url: "/api/products/sofa-test/fabrics", cookies: f.cookies, payload: { fabricCode: "testcol", sides: ["S"], prune: true } }); assert.equal(bind2.statusCode, 200); const pruned = bind2.json().pruned as { removed: number; releasedEans: number; skipped: Array<{ id: string }> }; assert.equal(pruned.removed, 3); assert.equal(pruned.releasedEans, 3); assert.equal(pruned.skipped.length, 1); assert.equal(pruned.skipped[0]!.id, protectedVariant.id); variants = listVariants(f.db, f.product.id); // 2 nowe S + 1 chroniony L assert.equal(variants.filter((v) => v.side === "S").length, 2); assert.equal(variants.filter((v) => v.side === "P").length, 0); // EAN chronionego wariantu zostaje; bilans puli: // 6 start − 4 przydzielone + 3 zwolnione − 2 nowe S = 3 dostępne assert.ok(eanForVariant(f.db, protectedVariant.id)); const free = (f.db.prepare("SELECT COUNT(*) c FROM ean_pool WHERE status='available'").get() as { c: number }).c; assert.equal(free, 3); // warianty S mają własne EAN-y z puli assert.ok(variants.filter((v) => v.side === "S").every((v) => v.ean)); }); // --- color_hex w runFabricImport ---------------------------------------------- const SIC_URL = "https://sic.com.pl/oferta/tkaniny/nova/"; const SIC_HTML = `

Kolekcja NOVA

`; test("fabric import: color_hex wyliczany z bufora próbki przez sharp", async (t) => { const f = await fixture(t); const fetcher = async (url: string, kind: string): Promise => { if (kind === "fabric_page") return { buffer: Buffer.from(SIC_HTML), mime: "text/html", finalUrl: url, redirects: [], fetchedAt: "2026-09-02T00:00:00.000Z" }; // jednolita próbka RGB(163,139,114) → oczekiwane ~#a38b72 const buffer = await sharp({ create: { width: 16, height: 16, channels: 3, background: { r: 163, g: 139, b: 114 } } }).jpeg().toBuffer(); return { buffer, mime: "image/jpeg", finalUrl: url, redirects: [], fetchedAt: "2026-09-02T00:00:00.000Z" }; }; const res = enqueueFabricImport(f.db, { url: SIC_URL, idempotencyKey: "col1", actor: f.actor }); claimNextJob(f.db); await runFabricImport(f.db, f.cfg, res.import.job_id, fetcher); const fabric = f.db.prepare("SELECT * FROM fabrics WHERE code = 'nova'").get() as { id: string }; const shade = listShades(f.db, fabric.id).find((s) => s.code === "01")!; assert.match(shade.color_hex ?? "", /^#[0-9a-f]{6}$/); const [r, g, b] = [1, 3, 5].map((i) => parseInt(shade.color_hex!.slice(i, i + 2), 16)); assert.ok(Math.abs(r! - 163) <= 4 && Math.abs(g! - 139) <= 4 && Math.abs(b! - 114) <= 4, `oczekiwano ~#a38b72, jest ${shade.color_hex}`); }); // --- upsertShade: NULL uzupełniany, wartość zachowana -------------------------- test("upsertShade: NULL w color_hex nadpisywany nowym hexem, istniejąca wartość zachowana", async (t) => { const f = await fixture(t); const fabric = upsertFabric(f.db, { code: "c2", name: "C2", producer: "T" }); upsertShade(f.db, fabric.id, { code: "07" }); let shade = listShades(f.db, fabric.id).find((s) => s.code === "07")!; assert.equal(shade.color_hex, null); upsertShade(f.db, fabric.id, { code: "07", colorHex: "#a38b72" }); shade = listShades(f.db, fabric.id).find((s) => s.code === "07")!; assert.equal(shade.color_hex, "#a38b72"); // ponowny import bez koloru (np. nieudany decode) nie niszczy wartości upsertShade(f.db, fabric.id, { code: "07", colorHex: null }); shade = listShades(f.db, fabric.id).find((s) => s.code === "07")!; assert.equal(shade.color_hex, "#a38b72"); });