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 { fileURLToPath } from "node:url"; import sharp from "sharp"; import Fastify from "fastify"; import cookie from "@fastify/cookie"; 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 { createProduct, getProductBySlug, deleteProduct } from "../src/domain/product.js"; import { ingestUpload, listSources, sourceBelongsToProduct } from "../src/media/ingest.js"; import { assignShotRef } from "../src/domain/shotRefs.js"; import { enqueueProductImport, runProductImport, refetchProductImages } from "../src/domain/productImport.js"; import { claimNextJob, getJob } from "../src/domain/jobs.js"; import type { FetchedSource } from "../src/media/sourceFetch.js"; const FIXTURE = fs.readFileSync( path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "bobochic-product.html"), "utf8" ); const PAGE_URL = "https://bobochicparis.com/fr/canapes-d-angle-convertibles/18302-canape-d-angle-convertible-fuji-tissu-lisse.html#/7-couleur-beige/244-type_d_angle-angle_droit"; async function fixture(t: { after: (fn: () => Promise) => void }) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-links-")); 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 }; return { db, app, cfg, cookies, actor }; } const imgBuf = () => sharp({ create: { width: 24, height: 18, channels: 3, background: { r: 50, g: 90, b: 130 } } }).jpeg().toBuffer(); // Bajty deterministyczne per URL — ta sama strona w dwóch importach daje // identyczne pliki (dedup między produktami → linki), a różne pozycje // galerii nie zlewają się w jeden asset. const fetched = (html: string) => async (url: string, kind: string): Promise => { if (kind === "product_page") { return { buffer: Buffer.from(html), mime: "text/html", finalUrl: url.split("#")[0]!, redirects: [], fetchedAt: "2026-09-05T00:00:00.000Z" }; } const seed = [...url].reduce((a, c) => a + c.charCodeAt(0), 0); const buffer = await sharp({ create: { width: 24, height: 18, channels: 3, background: { r: seed % 256, g: (seed * 7) % 256, b: (seed * 13) % 256 } } }).jpeg().toBuffer(); return { buffer, mime: "image/jpeg", finalUrl: url, redirects: [], fetchedAt: "2026-09-05T00:00:00.000Z" }; }; const linkedCount = (db: ReturnType, productId: string) => (db.prepare("SELECT COUNT(*) c FROM source_asset_products WHERE product_id = ?").get(productId) as { c: number }).c; test("ingestUpload: ta sama treść pod innym produktem → link, listSources widzi w obu, assignShotRef przepuszcza", async (t) => { const f = await fixture(t); const a = createProduct(f.db, { slug: "prod-a", name: "A", kind: "sofa" }); const b = createProduct(f.db, { slug: "prod-b", name: "B", kind: "sofa" }); const buf = await imgBuf(); const s1 = await ingestUpload(f.db, f.cfg, { buffer: buf, originalName: "p.jpg", productId: a.id, role: "vendor_reference", note: null, uploadedBy: null, sourceUrl: null, fetchedAt: null }); assert.equal(s1.duplicate, false); const s2 = await ingestUpload(f.db, f.cfg, { buffer: buf, originalName: "p.jpg", productId: b.id, role: "vendor_reference", note: null, uploadedBy: null, sourceUrl: null, fetchedAt: null }); assert.equal(s2.duplicate, true); assert.equal(s2.linked, true); assert.equal(s2.id, s1.id); assert.equal(linkedCount(f.db, b.id), 1); assert.equal(sourceBelongsToProduct(f.db, s1.id, b.id), true); // tylko JEDEN wiersz source_assets (sha UNIQUE), dwa produkty go widzą assert.equal((listSources(f.db, a.id) as Array<{ id: string }>).length, 1); assert.equal((listSources(f.db, b.id) as Array<{ id: string; linked: number }>)[0]!.linked, 1); // shot_refs akceptuje linkowane źródło const ref = assignShotRef(f.db, { productId: b.id, shotRole: "hero", sourceId: s1.id, refKind: "primary", actor: f.actor }); assert.equal(ref.source_id, s1.id); }); test("import tego samego produktu pod drugim slugiem: zdjęcia linkowane zamiast „innym produkcie”, refs przypisane", async (t) => { const f = await fixture(t); const r1 = enqueueProductImport(f.db, { url: PAGE_URL, slug: "fuji-a", fetchImages: true, idempotencyKey: "la", actor: f.actor }); claimNextJob(f.db); await runProductImport(f.db, f.cfg, r1.import.job_id, fetched(FIXTURE)); const res1 = JSON.parse(getJob(f.db, r1.import.job_id)!.result_json!) as { images: { stored: number } }; assert.ok(res1.images.stored > 0); const r2 = enqueueProductImport(f.db, { url: PAGE_URL, slug: "fuji-b", name: "FUJI B", fetchImages: true, idempotencyKey: "lb", actor: f.actor }); claimNextJob(f.db); await runProductImport(f.db, f.cfg, r2.import.job_id, fetched(FIXTURE)); const res2 = JSON.parse(getJob(f.db, r2.import.job_id)!.result_json!) as { images: { found: number; stored: number; duplicates: number; linked?: number; warnings: string[]; refsAssigned?: string[] }; }; assert.equal(res2.images.stored, 0); assert.equal(res2.images.linked, res2.images.found); assert.ok(!res2.images.warnings.some((w) => w.includes("innym produkcie")), res2.images.warnings.join()); assert.ok((res2.images.refsAssigned?.length ?? 0) > 0); const b = getProductBySlug(f.db, "fuji-b")!; assert.ok(linkedCount(f.db, b.id) > 0); assert.ok((listSources(f.db, b.id) as unknown[]).length > 0); }); test("refetchProductImages: odtwarza linki i refs ze snapshotu; endpoint wymaga sesji", async (t) => { const f = await fixture(t); const r = enqueueProductImport(f.db, { url: PAGE_URL, slug: "fuji-c", fetchImages: true, idempotencyKey: "lc", actor: f.actor }); claimNextJob(f.db); await runProductImport(f.db, f.cfg, r.import.job_id, fetched(FIXTURE)); const product = getProductBySlug(f.db, "fuji-c")!; const before = (listSources(f.db, product.id) as unknown[]).length; assert.ok(before > 0); // wyczyść linki+refs i pobierz ponownie ze snapshotu (bez sieci dla strony) f.db.prepare("DELETE FROM source_asset_products WHERE product_id = ?").run(product.id); f.db.prepare("DELETE FROM shot_refs WHERE product_id = ?").run(product.id); const out = await refetchProductImages(f.db, f.cfg, { productId: product.id, actor: f.actor }, fetched(FIXTURE)); assert.equal(out.ambiguous, false); assert.ok(out.found > 0); assert.ok(out.refsAssigned.length > 0); assert.equal((listSources(f.db, product.id) as unknown[]).length, before); // trasa: 401 bez sesji, 404 dla nieznanego produktu, 200 dla produktu assert.equal((await f.app.inject({ method: "POST", url: "/api/products/fuji-c/refetch-images" })).statusCode, 401); assert.equal((await f.app.inject({ method: "POST", url: "/api/products/nope/refetch-images", cookies: f.cookies })).statusCode, 404); }); test("deleteProduct: właściciel skasowany → asset zostaje globalny dla linkujących; linkujący skasowany → tylko linki znikają", async (t) => { const f = await fixture(t); const a = createProduct(f.db, { slug: "own-a", name: "A", kind: "sofa" }); const b = createProduct(f.db, { slug: "lnk-b", name: "B", kind: "sofa" }); const s = await ingestUpload(f.db, f.cfg, { buffer: await imgBuf(), originalName: "x.jpg", productId: a.id, role: "vendor_reference", note: null, uploadedBy: null, sourceUrl: null, fetchedAt: null }); await ingestUpload(f.db, f.cfg, { buffer: await sharp({ create: { width: 24, height: 18, channels: 3, background: { r: 50, g: 90, b: 130 } } }).jpeg().toBuffer(), originalName: "x.jpg", productId: b.id, role: "vendor_reference", note: null, uploadedBy: null, sourceUrl: null, fetchedAt: null }); assert.equal(linkedCount(f.db, b.id), 1); // kasowanie właściciela: asset linkowany przez B przechodzi na product_id NULL, plik zostaje await deleteProduct(f.db, f.cfg, { slug: "own-a", actorId: f.actor }); const row = f.db.prepare("SELECT product_id, path FROM source_assets WHERE id = ?").get(s.id) as { product_id: string | null; path: string }; assert.equal(row.product_id, null); assert.ok(fs.existsSync(path.join(f.cfg.originalsDir, row.path)), "plik nadal istnieje dla B"); assert.equal((listSources(f.db, b.id) as Array<{ id: string }>).length, 1); // kasowanie linkującego: znikają linki, asset zostaje await deleteProduct(f.db, f.cfg, { slug: "lnk-b", actorId: f.actor }); assert.equal(linkedCount(f.db, b.id), 0); assert.ok(f.db.prepare("SELECT 1 x FROM source_assets WHERE id = ?").get(s.id)); });