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 { openDb, type Db } from "../src/db.js"; import type { AppConfig } from "../src/config.js"; import { createProduct, deleteProduct, getProductBySlug, ProductDeleteError } from "../src/domain/product.js"; import { setFact } from "../src/domain/facts.js"; import { upsertFabric, upsertShade, ensureVariantMatrix, listVariants } from "../src/domain/fabric.js"; import { enqueueJob } from "../src/domain/jobs.js"; import { registerRoutes } from "../src/routes.js"; import { createUser, login } from "../src/auth.js"; const NOW = "2026-09-22T00:00:00.000Z"; const sha = (c: string) => c.repeat(64); function base() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-del-")); 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: "TEST-NEVER-SEND", groqApiKey: null, baseApiToken: null, baseApiUrl: "https://example.invalid/connector.php", } as AppConfig; for (const d of [cfg.mediaDir, cfg.originalsDir, cfg.derivedDir]) fs.mkdirSync(d); const adminId = createUser(db, { login: "del-admin", password: "test-isolated-only", displayName: "Admin", role: "admin" }); createUser(db, { login: "del-operator", password: "test-isolated-only", displayName: "Op", role: "operator" }); const cleanup = () => { db.close(); fs.rmSync(dir, { recursive: true, force: true }); }; return { db, cfg, adminId, cleanup }; } /** * Produkt z jednym wierszem w każdej tabeli zależnej — wystarcza, by * udowodnić kolejność kasowania (FK) i sprzątanie plików. */ function populate(db: Db, cfg: AppConfig, slug: string, adminId: string) { const p = createProduct(db, { slug, name: `Produkt ${slug}`, kind: "corner-sofa" }); const pid = p.id; setFact(db, pid, { key: "dimensions.width_cm", value: 200, status: "confirmed", sourceKind: "owner", sourceRef: "t" }); // update zapisuje wiersz fact_history — wykonujemy realną zmianę wartości setFact(db, pid, { key: "dimensions.width_cm", value: 210, status: "confirmed", sourceKind: "owner", sourceRef: "t" }); const fabric = upsertFabric(db, { code: `fab-${slug}`, name: "FAB", producer: "T", verification: "confirmed" }); // próbka odcienia = source_asset TEGO produktu → ma zostać odłączona, nie skasowana fs.writeFileSync(path.join(cfg.originalsDir, `shared-${slug}.png`), "x"); db.prepare("INSERT INTO source_assets(id,product_id,role,sha256,mime,bytes,original_name,path,created_at) VALUES(?,?,?,?,?,?,?,?,?)") .run(`shared-${slug}`, pid, "fabric_reference", sha("b"), "image/png", 3, "shared.png", `shared-${slug}.png`, NOW); upsertShade(db, fabric.id, { code: "01", name: "kolor", officialImagePath: `shared-${slug}`, verification: "confirmed" }); ensureVariantMatrix(db, { productId: pid, fabricId: fabric.id, sides: ["L", "P"] }); const variant = listVariants(db, pid)[0]; // własny materiał źródłowy + plik fs.writeFileSync(path.join(cfg.originalsDir, `orig-${slug}.png`), "x"); db.prepare("INSERT INTO source_assets(id,product_id,role,sha256,mime,bytes,original_name,path,created_at) VALUES(?,?,?,?,?,?,?,?,?)") .run(`src-${slug}`, pid, "vendor_reference", sha("a"), "image/png", 1, "orig.png", `orig-${slug}.png`, NOW); db.prepare("INSERT INTO shot_refs(id,product_id,shot_role,source_id,ref_kind,created_at) VALUES(?,?,?,?,?,?)") .run(`sref-${slug}`, pid, "hero", `src-${slug}`, "primary", NOW); // asset + pliki + werdykt + akceptacja fs.writeFileSync(path.join(cfg.mediaDir, `asset-${slug}.png`), "x"); fs.mkdirSync(path.join(cfg.derivedDir, "thumbs"), { recursive: true }); fs.writeFileSync(path.join(cfg.derivedDir, "thumbs", `asset-${slug}.webp`), "x"); db.prepare("INSERT INTO assets(id,product_id,variant_id,kind,role,version,sha256,mime,bytes,path,thumb_path,status,origin,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)") .run(`ast-${slug}`, pid, variant.id, "image", "hero", 1, sha("c"), "image/png", 1, `asset-${slug}.png`, `thumbs/asset-${slug}.webp`, "approved", "derived", NOW); db.prepare("INSERT INTO macius_verdicts(id,asset_id,asset_sha256,model,prompt_version,verdict,created_at) VALUES(?,?,?,?,?,?,?)") .run(`mv-${slug}`, `ast-${slug}`, sha("c"), "macius-rules", "v1", "ok", NOW); db.prepare("INSERT INTO approvals(id,product_id,scope,target,asset_id,asset_sha256,variant_id,decision,decided_by,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)") .run(`ap-${slug}`, pid, "variant_media", "hero", `ast-${slug}`, sha("c"), variant.id, "approved", adminId, NOW); db.prepare("INSERT INTO channel_renditions(id,product_id,source_asset_id,source_sha256,variant_id,role,profile,sha256,mime,bytes,path,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)") .run(`ren-${slug}`, pid, `ast-${slug}`, sha("c"), variant.id, "hero", "base_v1", sha("d"), "image/jpeg", 1, `renditions/base_v1/${slug}.jpg`, NOW); fs.mkdirSync(path.join(cfg.derivedDir, "renditions", "base_v1"), { recursive: true }); fs.writeFileSync(path.join(cfg.derivedDir, "renditions", "base_v1", `${slug}.jpg`), "x"); // treści + paczka + outbox db.prepare("INSERT INTO offer_content_revisions(id,product_id,version,source_sha256,content_sha256,content_json,created_by,note,created_at) VALUES(?,?,?,?,?,?,?,?,?)") .run(`rev-${slug}`, pid, 1, sha("e"), sha("f"), "{}", adminId, "", NOW); db.prepare("INSERT INTO offer_content_decisions(id,revision_id,content_sha256,decision,decided_by,note,created_at) VALUES(?,?,?,?,?,?,?)") .run(`cd-${slug}`, `rev-${slug}`, sha("f"), "approved", adminId, "", NOW); db.prepare("INSERT INTO offer_snapshots(id,product_id,version,source_sha256,snapshot_sha256,payload_json,created_by,note,created_at) VALUES(?,?,?,?,?,?,?,?,?)") .run(`snap-${slug}`, pid, 1, sha("e"), sha("g"), "{}", adminId, "", NOW); db.prepare("INSERT INTO offer_snapshot_decisions(id,snapshot_id,snapshot_sha256,decision,decided_by,note,created_at) VALUES(?,?,?,?,?,?,?)") .run(`sd-${slug}`, `snap-${slug}`, sha("g"), "approved", adminId, "", NOW); db.prepare("INSERT INTO export_consents(id,product_id,snapshot_id,snapshot_sha256,source_sha256,scope_json,note,granted_by,granted_at) VALUES(?,?,?,?,?,?,?,?,?)") .run(`con-${slug}`, pid, `snap-${slug}`, sha("g"), sha("e"), "{}", "", adminId, NOW); const job = enqueueJob(db, { type: "export_dispatch", payload: { batchId: `batch-${slug}`, productId: pid }, idempotencyKey: `exp-${slug}` }); db.prepare("UPDATE jobs SET status = 'done' WHERE id = ?").run(job.id); db.prepare("INSERT INTO export_batches(id,product_id,consent_id,snapshot_id,snapshot_sha256,job_id,status,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)") .run(`batch-${slug}`, pid, `con-${slug}`, `snap-${slug}`, sha("g"), job.id, "done", adminId, NOW, NOW); db.prepare("INSERT INTO export_items(id,batch_id,product_id,kind,variant_id,position,sku,method,payload_json,payload_sha256,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)") .run(`item-${slug}`, `batch-${slug}`, pid, "variant", variant.id, 1, "SKU-1", "addInventoryProduct", "{}", "h", "verified", NOW, NOW); // import + operacje orkiestratora + inspekcja + receptura const impJob = enqueueJob(db, { type: "import_source", payload: { importId: `imp-${slug}` }, idempotencyKey: `imp-${slug}` }); db.prepare("UPDATE jobs SET status = 'done' WHERE id = ?").run(impJob.id); db.prepare("INSERT INTO source_imports(id,job_id,product_id,kind,requested_url,fetch_url,request_key,request_fingerprint,phase,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)") .run(`imp-${slug}`, impJob.id, pid, "product_page", "https://x.example/p", "https://x.example/p", `rk-${slug}`, "fp", "stored", adminId, NOW, NOW); db.prepare("INSERT INTO orchestrator_inspections(product_id,state_sha256,inspection_json,checked_at) VALUES(?,?,?,?)") .run(pid, sha("h"), "{}", NOW); db.prepare("INSERT INTO orchestrator_operations(id,product_id,request_key,request_sha256,action_json,preview_json,preview_sha256,dependencies_json,status,created_by,created_at,expires_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)") .run(`op-${slug}`, pid, `ork-${slug}`, sha("i"), "{}", "{}", sha("j"), "[]", "rejected", adminId, NOW, NOW, NOW); db.prepare("INSERT INTO recipes(id,product_id,kind,version,definition_json,created_at) VALUES(?,?,?,?,?,?)") .run(`rec-${slug}`, pid, `recolor-${slug}`, 1, "{}", NOW); return { product: p, variant, fabric }; } const count = (db: Db, table: string, pid: string) => (db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE product_id = ?`).get(pid) as { n: number }).n; const DEPENDENT_TABLES = [ "product_facts", "fact_history", "variants", "assets", "approvals", "shot_refs", "channel_renditions", "offer_content_revisions", "offer_snapshots", "export_consents", "export_batches", "export_items", "orchestrator_operations", "orchestrator_inspections", "source_imports", "recipes", ]; test("usuwanie produktu: kasuje wszystkie zależności, pliki i joby; próbka katalogowa zostaje odłączona", (t) => { const f = base(); t.after(f.cleanup); const { product: p, variant } = populate(f.db, f.cfg, "delme", f.adminId); for (const tbl of DEPENDENT_TABLES) assert.ok(count(f.db, tbl, p.id) > 0, `fixture: ${tbl} ma wiersz`); const sharedBefore = f.db.prepare("SELECT product_id FROM source_assets WHERE id = 'shared-delme'").get() as { product_id: string }; assert.equal(sharedBefore.product_id, p.id); const res = deleteProduct(f.db, f.cfg, { slug: "delme", actorId: f.adminId }); assert.equal(res.slug, "delme"); assert.ok(res.filesRemoved >= 3); assert.deepEqual(res.fileWarnings, []); assert.equal(getProductBySlug(f.db, "delme"), null); for (const tbl of DEPENDENT_TABLES) assert.equal(count(f.db, tbl, p.id), 0, `${tbl} pusta`); // tabele z kluczem pośrednim assert.equal((f.db.prepare("SELECT COUNT(*) n FROM macius_verdicts WHERE asset_id = 'ast-delme'").get() as { n: number }).n, 0); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM offer_content_decisions WHERE revision_id = 'rev-delme'").get() as { n: number }).n, 0); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM offer_snapshot_decisions WHERE snapshot_id = 'snap-delme'").get() as { n: number }).n, 0); // joby produktu skasowane assert.equal((f.db.prepare("SELECT COUNT(*) n FROM jobs WHERE json_extract(payload_json,'$.productId') = ?").get(p.id) as { n: number }).n, 0); // pliki zniknęły assert.ok(!fs.existsSync(path.join(f.cfg.originalsDir, "orig-delme.png"))); assert.ok(!fs.existsSync(path.join(f.cfg.mediaDir, "asset-delme.png"))); assert.ok(!fs.existsSync(path.join(f.cfg.derivedDir, "renditions", "base_v1", "delme.jpg"))); // próbka odcienia odłączona, nie skasowana; jej plik zostaje na dysku const sharedAfter = f.db.prepare("SELECT product_id FROM source_assets WHERE id = 'shared-delme'").get() as { product_id: string | null }; assert.equal(sharedAfter.product_id, null); assert.ok(fs.existsSync(path.join(f.cfg.originalsDir, "shared-delme.png"))); assert.ok((f.db.prepare("SELECT COUNT(*) n FROM variants WHERE fabric_id = ?").get(variant.fabric_id) as { n: number }).n === 0); // audyt zachowuje ślad usunięcia const auditRow = f.db.prepare("SELECT action, detail_json FROM audit_log WHERE entity = 'products' AND entity_id = ?").get(p.id) as { action: string; detail_json: string } | undefined; assert.equal(auditRow?.action, "product.deleted"); assert.match(auditRow!.detail_json, /delme/); }); test("usuwanie produktu: 409 przy aktywnym zadaniu, 404 dla nieznanego sluga", (t) => { const f = base(); t.after(f.cleanup); const { product: p } = populate(f.db, f.cfg, "busy", f.adminId); enqueueJob(f.db, { type: "generate_shot", payload: { productId: p.id, role: "hero" } }); assert.throws(() => deleteProduct(f.db, f.cfg, { slug: "busy", actorId: f.adminId }), (e: unknown) => { assert.ok(e instanceof ProductDeleteError); assert.equal((e as ProductDeleteError).statusCode, 409); return true; }); // nic nie skasowane assert.ok(getProductBySlug(f.db, "busy")); assert.ok(count(f.db, "product_facts", p.id) > 0); assert.throws(() => deleteProduct(f.db, f.cfg, { slug: "nie-ma", actorId: f.adminId }), (e: unknown) => (e as ProductDeleteError).statusCode === 404); }); test("usuwanie produktu przez API: sesja, rola approver/admin, 200 i czysta lista", async (t) => { const f = base(); t.after(f.cleanup); populate(f.db, f.cfg, "asd", f.adminId); const app = Fastify(); await app.register(cookie); registerRoutes(app, f.db, f.cfg); t.after(() => app.close()); const operator = { vilmal_session: login(f.db, "del-operator", "test-isolated-only")!.sessionId }; const admin = { vilmal_session: login(f.db, "del-admin", "test-isolated-only")!.sessionId }; assert.equal((await app.inject({ method: "DELETE", url: "/api/products/asd" })).statusCode, 401); assert.equal((await app.inject({ method: "DELETE", url: "/api/products/asd", cookies: operator })).statusCode, 403); assert.equal((await app.inject({ method: "DELETE", url: "/api/products/nie-ma", cookies: admin })).statusCode, 404); const res = await app.inject({ method: "DELETE", url: "/api/products/asd", cookies: admin }); assert.equal(res.statusCode, 200); assert.equal(res.json().deleted, true); assert.equal(res.json().slug, "asd"); const list = await app.inject({ url: "/api/products", cookies: admin }); assert.equal(list.statusCode, 200); assert.ok(!(list.json().products as Array<{ slug: string }>).some((p) => p.slug === "asd")); });