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 Fastify from "fastify"; import cookie from "@fastify/cookie"; import sharp from "sharp"; 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 { upsertFabric, upsertShade, ensureVariantMatrix } from "../src/domain/fabric.js"; import { createProduct } from "../src/domain/product.js"; import { ingestUpload } from "../src/media/ingest.js"; async function fixture(t: { after: (fn: () => Promise) => void }) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-fabric-")); const mediaDir = path.join(dir, "media"); const cfg = loadConfig({ env: "test", dataDir: dir, mediaDir, originalsDir: path.join(mediaDir, "originals"), derivedDir: path.join(mediaDir, "derived") }); const db = openDb(":memory:"); const app = Fastify(); t.after(async () => { await app.close(); db.close(); fs.rmSync(dir, { recursive: true, force: true }); }); await app.register(cookie); registerRoutes(app, db, cfg); createUser(db, { login: "reader", displayName: "Czytelnik", role: "operator", password: "test-only-password" }); const cookies = { vilmal_session: login(db, "reader", "test-only-password")!.sessionId }; const get = (url: string) => app.inject({ method: "GET", url, cookies }); return { db, cfg, app, get, dir }; } async function populated(t: Parameters[0]) { const f = await fixture(t); const img = await sharp({ create: { width: 16, height: 16, channels: 3, background: "#817364" } }).png().toBuffer(); const source = await ingestUpload(f.db, f.cfg, { buffer: img, originalName: "sample.png", role: "fabric_reference", sourceUrl: "https://sic.com.pl/sample.png", fetchedAt: "2026-09-01T00:00:00Z", note: "Próbka dostawcy" }); const pdf = await ingestUpload(f.db, f.cfg, { buffer: Buffer.from("%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF"), originalName: "card.pdf", role: "source_document" }); const fabric = upsertFabric(f.db, { code: "arena", name: "ARENA", producer: "SIC", params: { martindale_cycles: { min: 90000, max: 100000 }, notes: ["Do weryfikacji"] }, cardPath: pdf.path }); upsertShade(f.db, fabric.id, { code: "100", officialImagePath: source.id }); upsertShade(f.db, fabric.id, { code: "03", name: "beż", colorHex: "#817364", officialImagePath: source.id, photoPath: source.id, verification: "confirmed" }); const product = createProduct(f.db, { slug: "fuji", name: "FUJI", kind: "corner-sofa" }); ensureVariantMatrix(f.db, { productId: product.id, fabricId: fabric.id, sides: ["L", "P"] }); return { ...f, fabric, source, pdf }; } test("katalog: wszystkie nowe endpointy wymagają sesji", async (t) => { const f = await populated(t); for (const url of ["/api/fabrics", "/api/fabrics/arena", "/api/fabrics/arena/shades", "/api/fabrics/arena/card", `/api/fabrics/arena/media/${f.source.id}`, `/api/fabrics/arena/media/${f.source.id}/thumb`]) { assert.equal((await f.app.inject({ method: "GET", url })).statusCode, 401, url); } }); test("katalog: pusta baza, brak kolekcji i brak zapisów przy odczycie", async (t) => { const f = await fixture(t); assert.deepEqual((await f.get("/api/fabrics")).json(), { fabrics: [] }); for (const url of ["/api/fabrics/missing", "/api/fabrics/missing/shades", "/api/fabrics/missing/card"]) assert.equal((await f.get(url)).statusCode, 404); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM jobs").get() as { n: number }).n, 0); }); test("katalog: rzeczywiste dane, źródła globalne, sortowanie odcieni i relacje bez duplikatów", async (t) => { const f = await populated(t); const before = f.db.prepare("SELECT total_changes() n").get(); const list = await f.get("/api/fabrics"); assert.equal(list.statusCode, 200); assert.equal(list.json().fabrics[0].shadeCount, 2); const response = await f.get("/api/fabrics/arena"); const detail = response.json(); assert.equal(detail.fabric.verification, "unverified"); assert.deepEqual(detail.fabric.params.martindale_cycles, { min: 90000, max: 100000 }); assert.deepEqual(detail.shades.map((s: { code: string }) => s.code), ["03", "100"]); assert.equal(detail.shades[0].verification, "confirmed"); assert.equal(detail.shades[0].official.sourceId, f.source.id); assert.equal(detail.shades[0].official.sourceUrl, "https://sic.com.pl/sample.png"); assert.equal(detail.shades[0].official.fetchedAt, "2026-09-01T00:00:00Z"); assert.equal(detail.shades[0].official.status, "ready"); assert.equal(detail.shades[1].photo, null); assert.equal(detail.products.length, 1); assert.equal(detail.products[0].slug, "fuji"); assert.equal(detail.card.status, "ready"); assert.doesNotMatch(response.body, /card_path|official_image_path|photo_path|thumb_path|params_json/); assert.ok(!response.body.includes(f.cfg.originalsDir)); assert.deepEqual((await f.get("/api/fabrics/arena/shades")).json().shades, detail.shades); assert.deepEqual(f.db.prepare("SELECT total_changes() n").get(), before); }); test("katalog: obrazy, miniatury i PDF mają właściwe bajty i bezpieczne nagłówki", async (t) => { const f = await populated(t); const detail = (await f.get("/api/fabrics/arena")).json(); for (const [url, mime] of [[detail.shades[0].official.url, "image/png"], [detail.shades[0].official.thumbnailUrl, "image/webp"], [detail.card.url, "application/pdf"], ["/api/fabrics/arena/card", "application/pdf"]]) { const response = await f.get(url); assert.equal(response.statusCode, 200); assert.equal(response.headers["content-type"], mime); assert.equal(response.headers["x-content-type-options"], "nosniff"); assert.equal(response.headers["cache-control"], "private, no-store"); assert.ok(response.rawPayload.length > 10); } assert.equal((await f.get("/api/fabrics/arena/card")).body.slice(0, 5), "%PDF-"); upsertFabric(f.db, { code: "other", name: "Other", producer: "Test" }); assert.equal((await f.get(`/api/fabrics/other/media/${f.source.id}`)).statusCode, 404); }); test("katalog: brakujące pliki, niepoprawny MIME i ścieżki poza originals są niedostępne", async (t) => { const f = await populated(t); const url = `/api/fabrics/arena/media/${f.source.id}`; for (const badPath of ["missing.png", "../outside.png", "..\\outside.png", path.join(f.dir, "outside.png")]) { f.db.prepare("UPDATE source_assets SET path = ? WHERE id = ?").run(badPath, f.source.id); const detail = (await f.get("/api/fabrics/arena")).json(); assert.equal(detail.shades[0].official.status, "unavailable"); assert.equal(detail.shades[0].official.url, null); assert.equal((await f.get(url)).statusCode, 404); } f.db.prepare("UPDATE source_assets SET path = ?, mime = 'text/html' WHERE id = ?").run(f.source.path, f.source.id); assert.equal((await f.get(url)).statusCode, 404); f.db.prepare("UPDATE source_assets SET path = ?, mime = 'image/png' WHERE id = ?").run(f.pdf.path, f.source.id); assert.equal((await f.get(url)).statusCode, 404); f.db.prepare("UPDATE fabrics SET card_path = '../outside.pdf' WHERE id = ?").run(f.fabric.id); assert.equal((await f.get("/api/fabrics/arena/card")).statusCode, 404); }); test("katalog: weryfikacja tkaniny/odcienia — tylko approver, audyt, błędy", async (t) => { const f = await populated(t); createUser(f.db, { login: "ap", displayName: "Akceptujący", role: "approver", password: "test-only-password" }); const approver = { vilmal_session: login(f.db, "ap", "test-only-password")!.sessionId }; const operator = { vilmal_session: login(f.db, "reader", "test-only-password")!.sessionId }; const post = (url: string, cookies?: Record, payload?: unknown) => f.app.inject({ method: "POST", url, cookies, payload: payload as Record }); // auth + rola + walidacja assert.equal((await post("/api/fabrics/arena/verification", undefined, { verification: "confirmed" })).statusCode, 401); assert.equal((await post("/api/fabrics/arena/verification", operator, { verification: "confirmed" })).statusCode, 403); assert.equal((await post("/api/fabrics/arena/verification", approver, { verification: "maybe" })).statusCode, 400); assert.equal((await post("/api/fabrics/nope/verification", approver, { verification: "confirmed" })).statusCode, 404); assert.equal((await post("/api/fabrics/arena/shades/999/verification", approver, { verification: "confirmed" })).statusCode, 404); // potwierdzenie tkaniny i odcienia assert.equal((await post("/api/fabrics/arena/verification", approver, { verification: "confirmed" })).statusCode, 200); assert.equal((await post("/api/fabrics/arena/shades/100/verification", approver, { verification: "confirmed" })).statusCode, 200); let detail = (await f.get("/api/fabrics/arena")).json(); assert.equal(detail.fabric.verification, "confirmed"); assert.equal(detail.shades.find((s: { code: string }) => s.code === "100").verification, "confirmed"); // cofnięcie odcienia assert.equal((await post("/api/fabrics/arena/shades/100/verification", approver, { verification: "unverified" })).statusCode, 200); detail = (await f.get("/api/fabrics/arena")).json(); assert.equal(detail.shades.find((s: { code: string }) => s.code === "100").verification, "unverified"); const audits = f.db.prepare("SELECT action FROM audit_log WHERE action LIKE 'fabric%' ORDER BY rowid").all() as Array<{ action: string }>; assert.deepEqual(audits.map((a) => a.action), ["fabric.verification", "fabric_shade.verification", "fabric_shade.verification"]); }); test("katalog: nota struktury tkaniny — wybór PL → klauzula EN, surowa uwaga PL bez Groq, audyt", async (t) => { const f = await populated(t); const operator = { vilmal_session: login(f.db, "reader", "test-only-password")!.sessionId }; const put = (payload?: unknown, cookies?: Record) => f.app.inject({ method: "PUT", url: "/api/fabrics/arena/ai-prompt", cookies, payload: payload as Record }); assert.equal((await put({ structure: "boucle", notePl: null }, undefined)).statusCode, 401); assert.equal((await f.app.inject({ method: "PUT", url: "/api/fabrics/nope/ai-prompt", cookies: operator, payload: { structure: "boucle", notePl: null } })).statusCode, 404); assert.equal((await put({ notePl: "x" }, operator)).statusCode, 400); assert.equal((await put({ structure: "nope", notePl: null }, operator)).statusCode, 400); // typ struktury z listy → deterministyczna klauzula EN const set = await put({ structure: "boucle", notePl: null }, operator); assert.equal(set.statusCode, 200, set.body); assert.equal(set.json().fabric.aiStructure, "boucle"); assert.equal(set.json().fabric.aiPrompt, "looped bouclé-style pile, no ribs"); let detail = (await f.get("/api/fabrics/arena")).json(); assert.equal(detail.fabric.aiStructure, "boucle"); assert.ok(detail.structures.some((s: { key: string }) => s.key === "boucle")); // wolna uwaga PL — bez GROQ_API_KEY w teście trafia surowa do klauzuli const noted = await put({ structure: "boucle", notePl: "gęsta pętelka" }, operator); assert.equal(noted.statusCode, 200, noted.body); assert.equal(noted.json().fabric.aiNotePl, "gęsta pętelka"); assert.equal(noted.json().fabric.aiPrompt, "looped bouclé-style pile, no ribs, gęsta pętelka"); // czyszczenie całości const cleared = await put({ structure: null, notePl: null }, operator); assert.equal(cleared.statusCode, 200); detail = (await f.get("/api/fabrics/arena")).json(); assert.equal(detail.fabric.aiPrompt, null); assert.equal(detail.fabric.aiStructure, null); assert.equal( (f.db.prepare("SELECT COUNT(*) n FROM audit_log WHERE action = 'fabric.ai_prompt'").get() as { n: number }).n, 3 ); }); test("katalog: nie podąża za dowiązaniem poza katalog mediów", async (t) => { const f = await populated(t); const outside = path.join(f.dir, "outside"); fs.mkdirSync(outside); fs.writeFileSync(path.join(outside, "sample.png"), fs.readFileSync(path.join(f.cfg.originalsDir, f.source.path))); fs.symlinkSync(outside, path.join(f.cfg.originalsDir, "escape"), "junction"); f.db.prepare("UPDATE source_assets SET path = 'escape/sample.png' WHERE id = ?").run(f.source.id); assert.equal((await f.get(`/api/fabrics/arena/media/${f.source.id}`)).statusCode, 404); });