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 multipart from "@fastify/multipart"; import sharp from "sharp"; import { openDb } from "../src/db.js"; import type { AppConfig } from "../src/config.js"; import { createProduct } from "../src/domain/product.js"; import { upsertFabric, upsertShade, ensureVariantMatrix, listVariants } from "../src/domain/fabric.js"; import { addEans, eanForVariant } from "../src/domain/eanPool.js"; import { registerRoutes } from "../src/routes.js"; import { createUser, login } from "../src/auth.js"; /** * Krok 1 — kokpit źródła danych: upload z hali, przypisania ujęć (master), * odpięcie materiału, matryca wariantów z puli EAN, reimport Bobochic, * zbiorcza akceptacja faktów. Wszystko przez app.inject na izolowanej bazie. */ const EANS = ["5901234123457", "5901234123464", "5901234123471"]; function base() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-k1-")); const db = openDb(":memory:"); const cfg = { dataDir: dir, mediaDir: path.join(dir, "media"), originalsDir: path.join(dir, "originals"), derivedDir: path.join(dir, "derived"), env: "test", runcomfyApiKey: null, groqApiKey: null, baseApiToken: null, baseApiUrl: "https://example.invalid/connector.php", maxUploadBytes: 25 * 1024 * 1024, } as AppConfig; for (const d of [cfg.mediaDir, cfg.originalsDir, cfg.derivedDir]) fs.mkdirSync(d); createUser(db, { login: "k1-admin", password: "test-isolated-only", displayName: "Admin", role: "admin" }); createUser(db, { login: "k1-op", password: "test-isolated-only", displayName: "Op", role: "operator" }); const cleanup = () => { db.close(); fs.rmSync(dir, { recursive: true, force: true }); }; return { db, cfg, cleanup }; } async function appWith(f: ReturnType) { const app = Fastify(); await app.register(cookie); await app.register(multipart, { limits: { fileSize: f.cfg.maxUploadBytes } }); registerRoutes(app, f.db, f.cfg); return app; } const png = () => sharp({ create: { width: 48, height: 32, channels: 3, background: "#8a6" } }).png().toBuffer(); function multipartBody(boundary: string, fields: Record, file: Buffer, filename = "hala.png") { const parts: Buffer[] = []; for (const [k, v] of Object.entries(fields)) { parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`)); } parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="${filename}"\r\nContent-Type: image/png\r\n\r\n`)); parts.push(file); parts.push(Buffer.from(`\r\n--${boundary}--\r\n`)); return Buffer.concat(parts); } test("k1: upload zdjęcia z hali → source_assets + plik na dysku", async (t) => { const f = base(); t.after(f.cleanup); const app = await appWith(f); t.after(() => app.close()); const admin = { vilmal_session: login(f.db, "k1-admin", "test-isolated-only")!.sessionId }; createProduct(f.db, { slug: "oskar", name: "OSKAR", kind: "corner-sofa" }); const b = "----k1boundary"; const res = await app.inject({ method: "POST", url: "/api/products/oskar/sources", cookies: admin, headers: { "content-type": `multipart/form-data; boundary=${b}` }, payload: multipartBody(b, { role: "product_reference" }, await png()), }); assert.equal(res.statusCode, 200, res.body); const body = res.json() as { uploaded: Array<{ id: string; sha256: string; path: string }>; errors: string[] }; assert.equal(body.errors.length, 0); assert.equal(body.uploaded.length, 1); const src = body.uploaded[0]; const row = f.db.prepare("SELECT * FROM source_assets WHERE id = ?").get(src.id) as { product_id: string; role: string; path: string }; assert.equal(row.role, "product_reference"); assert.ok(fs.existsSync(path.join(f.cfg.originalsDir, row.path)), "oryginał na dysku"); }); test("k1: przypisanie typu ujęcia i master packshot (shot_refs)", async (t) => { const f = base(); t.after(f.cleanup); const app = await appWith(f); t.after(() => app.close()); const admin = { vilmal_session: login(f.db, "k1-admin", "test-isolated-only")!.sessionId }; const p = createProduct(f.db, { slug: "oskar", name: "OSKAR", kind: "corner-sofa" }); const ins = f.db.prepare("INSERT INTO source_assets(id,product_id,role,sha256,mime,bytes,original_name,path,created_at) VALUES(?,?,?,?,?,?,?,?,?)"); ins.run("src-a", p.id, "product_reference", "a".repeat(64), "image/png", 10, "a.png", "a.png", "2026-01-01T00:00:00Z"); ins.run("src-b", p.id, "product_reference", "b".repeat(64), "image/png", 10, "b.png", "b.png", "2026-01-01T00:00:00Z"); const assign = (role: string, sourceId: string) => app.inject({ method: "POST", url: "/api/products/oskar/shot-refs", cookies: admin, payload: { shotRole: role, sourceId, refKind: "primary" } }); assert.equal((await assign("packshot_front", "src-a")).statusCode, 200); // drugi master tej samej roli degraduje poprzednie primary do supporting assert.equal((await assign("packshot_front", "src-b")).statusCode, 200); const refs = f.db.prepare("SELECT source_id, ref_kind FROM shot_refs WHERE product_id = ? AND shot_role = 'packshot_front' ORDER BY created_at").all(p.id) as Array<{ source_id: string; ref_kind: string }>; assert.equal(refs.find((r) => r.source_id === "src-b")?.ref_kind, "primary"); assert.equal(refs.find((r) => r.source_id === "src-a")?.ref_kind, "supporting"); // rola poza planem fotograficznym odrzucana assert.equal((await assign("blueprint", "src-a")).statusCode, 422); // materiał spoza ról referencyjnych odrzucany f.db.prepare("INSERT INTO source_assets(id,product_id,role,sha256,mime,bytes,original_name,path,created_at) VALUES(?,?,?,?,?,?,?,?,?)") .run("doc-1", p.id, "source_document", "c".repeat(64), "application/pdf", 10, "d.pdf", "d.pdf", "2026-01-01T00:00:00Z"); assert.equal((await assign("detail", "doc-1")).statusCode, 422); }); test("k1: odpięcie materiału usuwa przypisania, plik zostaje", async (t) => { const f = base(); t.after(f.cleanup); const app = await appWith(f); t.after(() => app.close()); const admin = { vilmal_session: login(f.db, "k1-admin", "test-isolated-only")!.sessionId }; const p = createProduct(f.db, { slug: "oskar", name: "OSKAR", kind: "corner-sofa" }); fs.writeFileSync(path.join(f.cfg.originalsDir, "keep.png"), "x"); f.db.prepare("INSERT INTO source_assets(id,product_id,role,sha256,mime,bytes,original_name,path,created_at) VALUES(?,?,?,?,?,?,?,?,?)") .run("src-keep", p.id, "product_reference", "k".repeat(64), "image/png", 1, "keep.png", "keep.png", "2026-01-01T00:00:00Z"); f.db.prepare("INSERT INTO shot_refs(id,product_id,shot_role,source_id,ref_kind,created_at) VALUES(?,?,?,?,?,?)") .run("ref-1", p.id, "packshot_front", "src-keep", "primary", "2026-01-01T00:00:00Z"); const res = await app.inject({ method: "DELETE", url: "/api/products/oskar/sources/src-keep", cookies: admin }); assert.equal(res.statusCode, 200, res.body); assert.equal((res.json() as { removedShotRefs: number }).removedShotRefs, 1); const row = f.db.prepare("SELECT product_id FROM source_assets WHERE id = 'src-keep'").get() as { product_id: string | null }; assert.equal(row.product_id, null); const left = f.db.prepare("SELECT COUNT(*) c FROM shot_refs WHERE source_id = 'src-keep'").get() as { c: number }; assert.equal(left.c, 0, "shot_refs posprzątane"); assert.ok(fs.existsSync(path.join(f.cfg.originalsDir, "keep.png")), "oryginał nietknięty"); // obcy produkt → 404 assert.equal((await app.inject({ method: "DELETE", url: "/api/products/oskar/sources/src-keep", cookies: admin })).statusCode, 404); }); test("k1: matryca wariantów — EAN z puli, odjęcie szkicu zwraca EAN", async (t) => { const f = base(); t.after(f.cleanup); const app = await appWith(f); t.after(() => app.close()); const admin = { vilmal_session: login(f.db, "k1-admin", "test-isolated-only")!.sessionId }; const p = createProduct(f.db, { slug: "oskar", name: "OSKAR", kind: "corner-sofa" }); const fabric = upsertFabric(f.db, { code: "tilia", name: "TILIA", producer: "V", verification: "confirmed" }); upsertShade(f.db, fabric.id, { code: "01" }); upsertShade(f.db, fabric.id, { code: "02" }); addEans(f.db, [EANS[0]]); const bind = (shadeCodes: string[]) => app.inject({ method: "POST", url: "/api/products/oskar/fabrics", cookies: admin, payload: { fabricCode: "tilia", sides: ["S"], shadeCodes } }); assert.equal((await bind(["01"])).statusCode, 200); let vs = listVariants(f.db, p.id); assert.equal(vs.length, 1); assert.equal(eanForVariant(f.db, vs[0]!.id), EANS[0], "pierwszy wolny EAN trafia do wariantu"); // pusta pula: drugi odcień powstaje bez EAN-a (kontrakt puli — jawny brak) assert.equal((await bind(["01", "02"])).statusCode, 200); vs = listVariants(f.db, p.id); assert.equal(vs.length, 2); const orphan = vs.find((v) => v.shade_code === "02")!; assert.equal(eanForVariant(f.db, orphan.id), null); // doładowanie puli przez POST /ean-pool → automatyczny backfill const imp = await app.inject({ method: "POST", url: "/api/ean-pool", cookies: admin, payload: { eans: [EANS[1]] } }); assert.equal(imp.statusCode, 200); assert.equal((imp.json() as { backfilled: number }).backfilled, 1); assert.equal(eanForVariant(f.db, orphan.id), EANS[1]); // odjęcie szkicu → EAN wraca do puli const del = await app.inject({ method: "DELETE", url: `/api/products/oskar/variants/${orphan.id}`, cookies: admin }); assert.equal(del.statusCode, 200, del.body); assert.equal((del.json() as { releasedEans: number }).releasedEans, 1); assert.equal(listVariants(f.db, p.id).length, 1); const avail = f.db.prepare("SELECT COUNT(*) c FROM ean_pool WHERE status = 'available'").get() as { c: number }; assert.equal(avail.c, 1, "zwolniony EAN wraca do puli"); }); test("k1: reimport Bobochic do istniejącego produktu (tryby A/B)", async (t) => { const f = base(); t.after(f.cleanup); const app = await appWith(f); t.after(() => app.close()); const admin = { vilmal_session: login(f.db, "k1-admin", "test-isolated-only")!.sessionId }; const p = createProduct(f.db, { slug: "oskar", name: "OSKAR", kind: "corner-sofa" }); const res = await app.inject({ method: "POST", url: "/api/products/oskar/import", cookies: admin, payload: { url: "https://bobochicparis.com/corner-sofa-oskar.html", importImages: false, idempotencyKey: "k1-test-1" }, }); assert.equal(res.statusCode, 202, res.body); const imp = (res.json() as { import: { productId: string | null } }).import; assert.equal(imp.productId, p.id, "reimport jest powiązany z istniejącym produktem"); // idempotencja: ten sam klucz → ten sam import, bez duplikatu const again = await app.inject({ method: "POST", url: "/api/products/oskar/import", cookies: admin, payload: { url: "https://bobochicparis.com/corner-sofa-oskar.html", importImages: false, idempotencyKey: "k1-test-1" }, }); assert.equal(again.statusCode, 200); // obcy host odrzucany przez strażnik URL const bad = await app.inject({ method: "POST", url: "/api/products/oskar/import", cookies: admin, payload: { url: "https://evil.example/x", importImages: true, idempotencyKey: "k1-test-2" }, }); assert.equal(bad.statusCode, 400); }); test("k1: zbiorcza akceptacja faktów — operator 403, admin 200", async (t) => { const f = base(); t.after(f.cleanup); const app = await appWith(f); t.after(() => app.close()); const admin = { vilmal_session: login(f.db, "k1-admin", "test-isolated-only")!.sessionId }; const operator = { vilmal_session: login(f.db, "k1-op", "test-isolated-only")!.sessionId }; createProduct(f.db, { slug: "oskar", name: "OSKAR", kind: "corner-sofa" }); // operator może edytować fakt jako unverified — ale nie jako confirmed const put = (cookies: Record, status: string) => app.inject({ method: "PUT", url: "/api/products/oskar/facts/dimensions.width_cm", cookies, payload: { value: 240, status, sourceKind: "owner", sourceRef: "miarka hali", unit: "cm" }, }); assert.equal((await put(operator, "unverified")).statusCode, 200); assert.equal((await put(operator, "confirmed")).statusCode, 403); const deny = await app.inject({ method: "POST", url: "/api/products/oskar/facts/confirm", cookies: operator, payload: { keys: ["dimensions.width_cm"] } }); assert.equal(deny.statusCode, 403); const ok = await app.inject({ method: "POST", url: "/api/products/oskar/facts/confirm", cookies: admin, payload: { keys: ["dimensions.width_cm"] } }); assert.equal(ok.statusCode, 200, ok.body); assert.equal((ok.json() as { confirmed: number }).confirmed, 1); }); test("k1: scenariusz E2E — hall: upload → master → tkanina → EAN → potwierdzenie", async (t) => { const f = base(); t.after(f.cleanup); const app = await appWith(f); t.after(() => app.close()); const admin = { vilmal_session: login(f.db, "k1-admin", "test-isolated-only")!.sessionId }; const p = createProduct(f.db, { slug: "wlasny", name: "Własny mebel", kind: "sofa" }); const fabric = upsertFabric(f.db, { code: "tilia", name: "TILIA", producer: "V", verification: "confirmed" }); upsertShade(f.db, fabric.id, { code: "07" }); addEans(f.db, EANS); // 1. upload zdjęcia z hali const b = "----k1e2e"; const up = await app.inject({ method: "POST", url: "/api/products/wlasny/sources", cookies: admin, headers: { "content-type": `multipart/form-data; boundary=${b}` }, payload: multipartBody(b, { role: "product_reference" }, await png(), "IMG_4021.png"), }); assert.equal(up.statusCode, 200, up.body); const sourceId = (up.json() as { uploaded: Array<{ id: string }> }).uploaded[0]!.id; // 2. ★ master packshot + typ "Front" const ref = await app.inject({ method: "POST", url: "/api/products/wlasny/shot-refs", cookies: admin, payload: { shotRole: "packshot_front", sourceId, refKind: "primary" } }); assert.equal(ref.statusCode, 200, ref.body); // 3. tkanina → wariant z EAN-em const bind = await app.inject({ method: "POST", url: "/api/products/wlasny/fabrics", cookies: admin, payload: { fabricCode: "tilia", sides: ["S"], shadeCodes: ["07"] } }); assert.equal(bind.statusCode, 200, bind.body); const v = listVariants(f.db, p.id)[0]!; assert.ok(eanForVariant(f.db, v.id), "wariant dostał EAN z puli"); // 4. wariant nadrzędny + fakt → zbiorcza akceptacja await app.inject({ method: "PUT", url: "/api/products/wlasny/facts/offer.master_variant_id", cookies: admin, payload: { value: v.id, status: "unverified", sourceKind: "owner" } }); await app.inject({ method: "PUT", url: "/api/products/wlasny/facts/dimensions.width_cm", cookies: admin, payload: { value: 210, status: "unverified", sourceKind: "owner", unit: "cm" } }); const conf = await app.inject({ method: "POST", url: "/api/products/wlasny/facts/confirm", cookies: admin, payload: { keys: ["dimensions.width_cm", "offer.master_variant_id"] } }); assert.equal(conf.statusCode, 200); const detail = f.db.prepare("SELECT status FROM product_facts WHERE product_id = ? AND key = 'dimensions.width_cm'").get(p.id) as { status: string }; assert.equal(detail.status, "confirmed"); });