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 crypto from "node:crypto"; import sharp from "sharp"; import Fastify from "fastify"; import cookie from "@fastify/cookie"; import { openDb } from "../src/db.js"; import type { AppConfig } from "../src/config.js"; import { createProduct } from "../src/domain/product.js"; import { setFact } from "../src/domain/facts.js"; import { upsertFabric, upsertShade, ensureVariantMatrix, listVariants } from "../src/domain/fabric.js"; import { registerAsset, decide, getAsset } from "../src/domain/assets.js"; import { buildMediaReview, buildReviewContactSheet, decideMediaReview, OFFER_ROLES } from "../src/domain/mediaReview.js"; import { registerRoutes } from "../src/routes.js"; import { createUser, login } from "../src/auth.js"; import { enqueueJob, getJob } from "../src/domain/jobs.js"; import { startWorker } from "../src/worker.js"; import { buildOfferContent, saveContentRevision, getContentRevision, listContentRevisions, decideContentRevision } from "../src/domain/offerContent.js"; import { buildOfferPackage, saveOfferSnapshot, getOfferSnapshot, decideOfferSnapshot } from "../src/domain/offerPackage.js"; import { buildMissingRenditions, getRendition, renditionFile } from "../src/domain/renditions.js"; async function fixture() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-media-review-")); 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 } as AppConfig; for (const d of [cfg.mediaDir, cfg.originalsDir, cfg.derivedDir]) fs.mkdirSync(d); const p = createProduct(db, { slug: "oskar", name: "OSKAR", kind: "corner-sofa" }); const fabric = upsertFabric(db, { code: "tilia", name: "TILIA", producer: "Davis", verification: "confirmed" }); const png = await sharp({ create: { width: 8, height: 8, channels: 4, background: { r: 120, g: 120, b: 120, alpha: 1 } } }).png().toBuffer(); const sha = crypto.createHash("sha256").update(png).digest("hex"); fs.writeFileSync(path.join(cfg.originalsDir, "swatch.png"), png); fs.writeFileSync(path.join(cfg.mediaDir, "image.png"), png); db.prepare("INSERT INTO source_assets(id,role,sha256,mime,bytes,original_name,path,created_at) VALUES(?,?,?,?,?,?,?,?)").run("swatch", "fabric_reference", sha, "image/png", png.length, "swatch.png", "swatch.png", "now"); for (const code of ["01", "03", "08", "11", "17", "37", "39", "52", "56", "62", "75", "77", "83", "85", "86", "90", "100"]) upsertShade(db, fabric.id, { code, name: `kolor ${code}`, officialImagePath: "swatch", verification: "confirmed" }); ensureVariantMatrix(db, { productId: p.id, fabricId: fabric.id, sides: ["L", "P"] }); setFact(db, p.id, { key: "sides.photographed", value: "P", status: "confirmed", sourceKind: "owner" }); setFact(db, p.id, { key: "fabric.photographed_shade", value: "86", status: "confirmed", sourceKind: "owner" }); const variants = listVariants(db, p.id); const put = (role: typeof OFFER_ROLES[number], variantId: string | null = null, parentId: string | null = null, provenance: Record = {}) => registerAsset(db, { productId: p.id, variantId, kind: "image", role, sha256: sha, bytes: png.length, path: "image.png", mime: "image/png", origin: "derived", parentAssetId: parentId, provenance }); const master = put("hero"); decide(db, { productId: p.id, assetId: master.id, target: "hero", scope: "shot", decision: "approved", decidedBy: "test" }); const candidate = (index = 0, overrides: Record = {}) => { const v = variants[index]!; return put("hero", v.id, master.id, { baseAsset: { id: master.id, sha256: sha, role: "hero" }, variant: { side: v.side, shade: v.shade_code, shadeName: `kolor ${v.shade_code}` }, anchorShade: "86", mirrored: v.side === "L", swatches: { target: "swatch", anchor: "swatch" }, ...overrides }); }; const approve = (id: string) => { const a = getAsset(db, id)!; decide(db, { productId: p.id, assetId: a.id, variantId: a.variant_id, target: a.role, scope: "variant_media", decision: "approved", decidedBy: "test" }); }; const cleanup = () => { db.close(); fs.rmSync(dir, { recursive: true, force: true }); }; return { db, cfg, p, variants, put, candidate, approve, master, sha, cleanup }; } test("treści: 34 właściwe SKU, pięć profili i brak niepotwierdzonych obietnic", async (t) => { const f = await fixture(); t.after(f.cleanup); for (const [key, value] of Object.entries({ "commerce.sku_pattern": "VIL-OSK-{L|P}-TIL{kod}", "sleeping.width_cm": 135, "sleeping.length_cm": 190, "commerce.price_pln": 4399, "commerce.vat_rate": "zwolnienie podmiotowe", "warranty.months": 24, "lead_time.days": 14 })) setFact(f.db, f.p.id, { key, value, status: "confirmed", sourceKind: "owner", sourceRef: "test" }); setFact(f.db, f.p.id, { key: "dimensions.width_cm", value: 999, status: "unverified", sourceKind: "owner" }); const a = f.candidate(); f.approve(a.id); f.candidate(); const draft = buildOfferContent(f.db, f.p.id); assert.equal(draft.variants.length, 34); assert.equal(new Set(draft.variants.map((v) => v.sku)).size, 34); assert.equal(draft.publishable, false); assert.equal(draft.commerce.pricePln, 4399); for (const v of draft.variants) { assert.equal(v.sku, `VIL-OSK-${v.side}-TIL${v.shade}`); assert.equal(Object.keys(v.channels).length, 5); assert.ok(v.channels.allegro!.title.length <= 75); assert.ok(v.channels.allegro!.title.split(/\s+/).length >= 3); const publicCopy = JSON.stringify(Object.values(v.channels).map((c) => [c.title, c.shortDescription, c.sections])); assert.match(publicCopy, /135 × 190/); assert.doesNotMatch(publicCopy, /999|192|14 dni|Easy Clean|Pet Friendly|OEKO|ponad 100/); assert.doesNotMatch(JSON.stringify(v.channels.allegro!.sections), /gwarancj/i); assert.equal((v.channels.google!.extras.structured_description as { digital_source_type: string }).digital_source_type, "trained_algorithmic_media"); } assert.equal(draft.variants.find((v) => v.id === a.variant_id)!.gallery[0]!.id, a.id); assert.equal(draft.variants.filter((v) => v.gallery.length > 0).length, 1); }); test("treści: zmiana faktu zmienia hash projektu, odczyt nie tworzy zleceń ani zgód", async (t) => { const f = await fixture(); t.after(f.cleanup); const first = buildOfferContent(f.db, f.p.id); setFact(f.db, f.p.id, { key: "sleeping.length_cm", value: 190, status: "confirmed", sourceKind: "owner", sourceRef: "test" }); assert.notEqual(buildOfferContent(f.db, f.p.id).draftSha256, first.draftSha256); assert.equal(f.db.prepare("SELECT COUNT(*) AS n FROM jobs").get()!.n, 0); assert.equal(f.db.prepare("SELECT COUNT(*) AS n FROM approvals").get()!.n, 1); }); test("treści: niezmienne wersje 34 × 5, deduplikacja i akceptacja konkretnego SHA", async (t) => { const f = await fixture(); t.after(f.cleanup); const draft = buildOfferContent(f.db, f.p.id); const input = { productId: f.p.id, expectedDraftSha256: draft.draftSha256, edits: [], actor: "test-editor", note: "Projekt do oceny" }; const first = saveContentRevision(f.db, input); assert.equal(first.content.variants.length, 34); assert.equal(first.content.variants.reduce((n, v) => n + Object.keys(v.channels).length, 0), 170); assert.equal(saveContentRevision(f.db, input).id, first.id); assert.equal(listContentRevisions(f.db, f.p.id).length, 1); const approved = decideContentRevision(f.db, { productId: f.p.id, revisionId: first.id, expectedSha256: first.sha256, decision: "approved", actor: "test-approver", note: "Przegląd 170 treści i faktów" }); assert.equal(approved.decision?.decision, "approved"); assert.equal(approved.content.publishable, false); const variant = first.content.variants[0]!; const content = { ...variant.channels.google!, title: `${variant.channels.google!.title} do salonu` }; const second = saveContentRevision(f.db, { ...input, baseRevisionId: first.id, baseRevisionSha256: first.sha256, edits: [{ variantId: variant.id, channel: "google", content }] }); assert.equal(second.version, 2); assert.notEqual(second.sha256, first.sha256); assert.equal(second.decision, null); assert.equal(getContentRevision(f.db, f.p.id, first.id).sha256, first.sha256); assert.equal(getContentRevision(f.db, f.p.id, first.id).decision?.decision, "approved"); assert.equal((second.content.variants[0]!.channels.google!.extras.structured_title as { content: string }).content, content.title); assert.equal(second.content.variants[1]!.channels.google!.title, first.content.variants[1]!.channels.google!.title); assert.throws(() => decideContentRevision(f.db, { productId: f.p.id, revisionId: second.id, expectedSha256: first.sha256, decision: "approved", actor: "test", note: "Błędna treść" }), /SHA/); assert.equal(f.db.prepare("SELECT COUNT(*) AS n FROM jobs").get()!.n, 0); }); test("treści: zmiana źródeł blokuje stare zgody i automatyczne przenoszenie edycji", async (t) => { const f = await fixture(); t.after(f.cleanup); const input = { productId: f.p.id, expectedDraftSha256: buildOfferContent(f.db, f.p.id).draftSha256, edits: [], actor: "test", note: "Projekt" }; const first = saveContentRevision(f.db, input); setFact(f.db, f.p.id, { key: "sleeping.length_cm", value: 190, status: "confirmed", sourceKind: "owner", sourceRef: "test" }); assert.equal(getContentRevision(f.db, f.p.id, first.id).current, false); assert.throws(() => saveContentRevision(f.db, input), /nieaktualn/); assert.throws(() => saveContentRevision(f.db, { ...input, expectedDraftSha256: buildOfferContent(f.db, f.p.id).draftSha256, baseRevisionId: first.id, baseRevisionSha256: first.sha256 }), /nieaktualn/); assert.throws(() => decideContentRevision(f.db, { productId: f.p.id, revisionId: first.id, expectedSha256: first.sha256, decision: "approved", actor: "test", note: "Przegląd" }), /nieaktualn/); assert.throws(() => getContentRevision(f.db, "other-product", first.id), /Nie znaleziono/); }); test("treści: edycja nie dopisuje liczb, certyfikatów, HTML ani obcego wariantu", async (t) => { const f = await fixture(); t.after(f.cleanup); const draft = buildOfferContent(f.db, f.p.id), variant = draft.variants[0]!; const input = { productId: f.p.id, expectedDraftSha256: draft.draftSha256, actor: "test", note: "Projekt" }; const wrongSide = variant.side === "L" ? ["To narożnik prawy", "Strona prawa", "Szezlong po prawej", "Układ prawostronny"] : ["To narożnik lewy", "Strona lewa", "Szezlong po lewej", "Układ lewostronny"]; for (const shortDescription of ["Spanie 140 × 200 cm", "Tkanina Easy Clean", "", "OEKO-TEX certyfikat", ...wrongSide, "Odcień TILIA 83"]) { assert.throws(() => saveContentRevision(f.db, { ...input, edits: [{ variantId: variant.id, channel: "main", content: { ...variant.channels.main!, shortDescription } }] })); } assert.throws(() => saveContentRevision(f.db, { ...input, edits: [{ variantId: "foreign", channel: "main", content: variant.channels.main! }] })); assert.equal(listContentRevisions(f.db, f.p.id).length, 0); }); test("API treści: zapis wymaga sesji, decyzja approvera i dokładny SHA", async (t) => { const f = await fixture(); t.after(f.cleanup); const app = Fastify(); await app.register(cookie); registerRoutes(app, f.db, f.cfg); t.after(() => app.close()); createUser(f.db, { login: "content-operator", password: "test-isolated-only", displayName: "operator", role: "operator" }); createUser(f.db, { login: "content-approver", password: "test-isolated-only", displayName: "approver", role: "approver" }); const operator = { vilmal_session: login(f.db, "content-operator", "test-isolated-only")!.sessionId }; const approver = { vilmal_session: login(f.db, "content-approver", "test-isolated-only")!.sessionId }; const url = "/api/products/oskar/offer-content"; assert.equal((await app.inject({ url: `${url}/revisions` })).statusCode, 401); const draft = await app.inject({ url, cookies: operator }); assert.equal(draft.statusCode, 200); assert.ok(!draft.body.includes("TEST-NEVER-SEND")); const saved = await app.inject({ method: "POST", url: `${url}/revisions`, cookies: operator, payload: { expectedDraftSha256: draft.json().draftSha256, edits: [], note: "zestaw do oceny" } }); assert.equal(saved.statusCode, 200); const revision = saved.json(); assert.equal(revision.current, true); assert.equal(revision.decision, null); assert.equal((await app.inject({ method: "POST", url: `${url}/revisions/${revision.id}/decision`, cookies: operator, payload: { expectedSha256: revision.sha256, decision: "approved", note: "przegląd" } })).statusCode, 403); assert.equal((await app.inject({ method: "POST", url: `${url}/revisions/${revision.id}/decision`, cookies: approver, payload: { expectedSha256: draft.json().draftSha256, decision: "approved", note: "zły SHA" } })).statusCode, 409); const decided = await app.inject({ method: "POST", url: `${url}/revisions/${revision.id}/decision`, cookies: approver, payload: { expectedSha256: revision.sha256, decision: "approved", note: "przegląd 170 treści" } }); assert.equal(decided.statusCode, 200); assert.equal(decided.json().decision.decision, "approved"); assert.equal((await app.inject({ method: "POST", url: `${url}/revisions`, cookies: operator, payload: { expectedDraftSha256: "0".repeat(64), edits: [], note: "stary projekt" } })).statusCode, 409); }); test("paczka: podgląd bez tokena, snapshot tylko dla dokładnego SHA, zmiana źródeł unieważnia", async (t) => { const f = await fixture(); t.after(f.cleanup); const a = f.candidate(); f.approve(a.id); const pack = buildOfferPackage(f.db, f.cfg, f.p.id); assert.equal(pack.exportAllowed, false); assert.equal(pack.base.send, false); assert.equal(pack.base.network, "none"); assert.ok(pack.blockers.length > 0); assert.ok(pack.blockers.some((b) => /zatwierdzonej i aktualnej wersji treści/.test(b))); assert.ok(pack.blockers.some((b) => /EAN/.test(b))); assert.ok(pack.blockers.some((b) => /gpsr\.manufacturer/.test(b))); assert.ok(pack.blockers.some((b) => /Wideo gotowe 0\/2/.test(b))); assert.ok(!JSON.stringify(pack).includes("TEST-NEVER-SEND")); assert.ok(!JSON.stringify(pack).includes(f.cfg.mediaDir)); const saved = saveOfferSnapshot(f.db, f.cfg, { productId: f.p.id, expectedSourceSha256: pack.sourceSha256, expectedSnapshotSha256: pack.snapshotSha256, note: "Podgląd do oceny — bez eksportu", actor: "test-operator" }); assert.equal(saved.version, 1); assert.equal(saved.payload.snapshotSha256, pack.snapshotSha256); assert.equal(saveOfferSnapshot(f.db, f.cfg, { productId: f.p.id, expectedSourceSha256: pack.sourceSha256, expectedSnapshotSha256: pack.snapshotSha256, note: "Ten sam stan", actor: "test-operator" }).id, saved.id); assert.throws(() => saveOfferSnapshot(f.db, f.cfg, { productId: f.p.id, expectedSourceSha256: "0".repeat(64), expectedSnapshotSha256: pack.snapshotSha256, note: "Stary podgląd", actor: "test" }), /nieaktualny/); assert.throws(() => decideOfferSnapshot(f.db, f.cfg, { productId: f.p.id, snapshotId: saved.id, expectedSha256: saved.sha256, decision: "approved", note: "Zaakceptuję z blokadami", actor: "test-approver" }), /blokadami/); const rejected = decideOfferSnapshot(f.db, f.cfg, { productId: f.p.id, snapshotId: saved.id, expectedSha256: saved.sha256, decision: "rejected", note: "Paczka niekompletna", actor: "test-approver" }); assert.equal(rejected.decision?.decision, "rejected"); setFact(f.db, f.p.id, { key: "care.instructions", value: "instrukcja testowa", status: "confirmed", sourceKind: "owner", sourceRef: "test" }); assert.equal(getOfferSnapshot(f.db, f.cfg, f.p.id, saved.id).current, false); assert.throws(() => decideOfferSnapshot(f.db, f.cfg, { productId: f.p.id, snapshotId: saved.id, expectedSha256: saved.sha256, decision: "approved", note: "Przestarzały snapshot", actor: "test-approver" }), /nieaktualne/); assert.equal(f.db.prepare("SELECT COUNT(*) AS n FROM jobs").get()!.n, 0); }); test("renditions: deterministyczny JPEG z zatwierdzonego assetu, deduplikacja i manifest paczki", async (t) => { const f = await fixture(); t.after(f.cleanup); const a = f.candidate(); f.approve(a.id); const first = await buildMissingRenditions(f.db, f.cfg, f.p.id); assert.equal(first.ready, 1); assert.equal(first.created, 1); const row = getRendition(f.db, (f.db.prepare("SELECT id FROM channel_renditions WHERE product_id = ?").get(f.p.id)! as { id: string }).id)!; const file = renditionFile(f.cfg, row); assert.ok(file); assert.equal(file!.mime, "image/jpeg"); assert.equal(crypto.createHash("sha256").update(fs.readFileSync(file!.abs)).digest("hex"), row.sha256); const second = await buildMissingRenditions(f.db, f.cfg, f.p.id); assert.equal(second.reused, 1); assert.equal(second.created, 0); const pack = buildOfferPackage(f.db, f.cfg, f.p.id); assert.equal(pack.renditions.expected, 272); assert.equal(pack.renditions.ready, 1); assert.equal(pack.renditions.items.find((r) => r.sourceAssetId === a.id)?.status, "ready"); assert.ok(!JSON.stringify(pack).includes(f.cfg.derivedDir)); }); test("API paczki: zapis i decyzja wymagają sesji/roli, dry-run nie tworzy eksportu", async (t) => { const f = await fixture(); t.after(f.cleanup); const app = Fastify(); await app.register(cookie); registerRoutes(app, f.db, f.cfg); t.after(() => app.close()); createUser(f.db, { login: "package-operator", password: "test-isolated-only", displayName: "operator", role: "operator" }); createUser(f.db, { login: "package-approver", password: "test-isolated-only", displayName: "approver", role: "approver" }); const operator = { vilmal_session: login(f.db, "package-operator", "test-isolated-only")!.sessionId }; const approver = { vilmal_session: login(f.db, "package-approver", "test-isolated-only")!.sessionId }; const url = "/api/products/oskar/offer-package"; assert.equal((await app.inject({ url })).statusCode, 401); const response = await app.inject({ url, cookies: operator }); assert.equal(response.statusCode, 200); const pack = response.json(); assert.equal(pack.base.send, false); assert.ok(!response.body.includes("TEST-NEVER-SEND")); const saved = await app.inject({ method: "POST", url: `${url}/snapshots`, cookies: operator, payload: { expectedSourceSha256: pack.sourceSha256, expectedSnapshotSha256: pack.snapshotSha256, note: "snapshot do przeglądu" } }); assert.equal(saved.statusCode, 200); assert.equal(saved.json().payload.exportGate.liveWriteAllowed, false); const snapshot = saved.json(); assert.equal((await app.inject({ method: "POST", url: `${url}/snapshots/${snapshot.id}/decision`, cookies: operator, payload: { expectedSha256: snapshot.sha256, decision: "rejected", note: "braki" } })).statusCode, 403); assert.equal((await app.inject({ method: "POST", url: `${url}/snapshots/${snapshot.id}/decision`, cookies: approver, payload: { expectedSha256: snapshot.sha256, decision: "approved", note: "niekompletne" } })).statusCode, 409); const rejected = await app.inject({ method: "POST", url: `${url}/snapshots/${snapshot.id}/decision`, cookies: approver, payload: { expectedSha256: snapshot.sha256, decision: "rejected", note: "braki potwierdzone" } }); assert.equal(rejected.statusCode, 200); assert.equal(rejected.json().decision.decision, "rejected"); }); test("arkusz przeglądu: PNG i manifest pokazują zatwierdzony asset, bez zmiany akceptacji", async (t) => { const f = await fixture(); t.after(f.cleanup); const a = f.candidate(); f.approve(a.id); f.candidate(); const before = f.db.prepare("SELECT COUNT(*) AS n FROM approvals").get()!.n; const result = await buildReviewContactSheet(f.db, f.cfg, f.p.id, "hero"); const meta = await sharp(result.path).metadata(); assert.equal(meta.format, "png"); assert.equal(result.count, 34); const manifest = JSON.parse(fs.readFileSync(result.path.replace(/\.png$/, ".json"), "utf8")); assert.equal(manifest.items[1].id, a.id); assert.equal(manifest.items[1].sha256, a.sha256); assert.equal(f.db.prepare("SELECT COUNT(*) AS n FROM approvals").get()!.n, before); }); test("przegląd: historyczny brak mirrored nie blokuje strony mastera, ale nie usprawiedliwia lustra L", async (t) => { const f = await fixture(); t.after(f.cleanup); const p = f.candidate(17, { mirrored: undefined }); f.approve(p.id); const l = f.candidate(0, { mirrored: undefined }); const review = buildMediaReview(f.db, f.cfg, f.p.id); const right = review.slots.find((s) => s.asset?.id === p.id)!; const left = review.slots.find((s) => s.asset?.id === l.id)!; assert.equal(right.ready, true); assert.ok(right.asset!.warnings.some((w) => /mirrored/.test(w))); assert.ok(left.asset!.blockers.some((b) => /orientacja/.test(b))); assert.equal(getAsset(f.db, p.id)!.provenance_json.includes("mirrored"), false); }); test("przegląd: 272 powiązania, zatwierdzone zamiast najnowszego, odrzucone nie wracają", async (t) => { const f = await fixture(); t.after(f.cleanup); const first = f.candidate(); f.approve(first.id); const newer = f.candidate(); const rejected = f.candidate(1); decide(f.db, { productId: f.p.id, assetId: rejected.id, variantId: rejected.variant_id, target: "hero", scope: "variant_media", decision: "rejected", decidedBy: "test" }); const review = buildMediaReview(f.db, f.cfg, f.p.id); assert.equal(review.summary.required, 272); assert.equal(review.summary.variants, 34); assert.equal(review.summary.approved, 1); assert.equal(review.summary.ready, 1); const slot = review.slots.find((s) => s.variantId === first.variant_id && s.role === "hero")!; assert.equal(slot.asset?.id, first.id); assert.equal(slot.candidates[0]?.id, newer.id); assert.equal(review.slots.find((s) => s.variantId === rejected.variant_id && s.role === "hero")?.asset, null); assert.equal(getAsset(f.db, rejected.id)?.status, "rejected"); assert.ok(!JSON.stringify(review).includes("TEST-NEVER-SEND")); assert.ok(!JSON.stringify(review).includes(f.cfg.mediaDir)); }); test("przegląd: stary master, zły odcień i zmienione bajty są blokadami", async (t) => { const f = await fixture(); t.after(f.cleanup); const a = f.candidate(0, { variant: { side: "P", shade: "77" } }); let review = buildMediaReview(f.db, f.cfg, f.p.id); assert.ok(review.slots.find((s) => s.asset?.id === a.id)!.asset!.blockers.some((b) => /Strona lub odcień/.test(b))); const parent = f.put("hero"); decide(f.db, { productId: f.p.id, assetId: parent.id, target: "hero", scope: "shot", decision: "approved", decidedBy: "test" }); fs.writeFileSync(path.join(f.cfg.mediaDir, "image.png"), "changed test file"); review = buildMediaReview(f.db, f.cfg, f.p.id); const blockers = review.slots.find((s) => s.asset?.id === a.id)!.asset!.blockers; assert.ok(blockers.some((b) => /master/.test(b))); assert.ok(blockers.some((b) => /SHA-256/.test(b))); }); test("decyzja zbiorcza: jawne ID/SHA, atomowość, nieaktualny przegląd i duplikaty blokowane", async (t) => { const f = await fixture(); t.after(f.cleanup); const a = f.candidate(0), b = f.candidate(1); const snapshot = buildMediaReview(f.db, f.cfg, f.p.id); const input = { productId: f.p.id, reviewSha256: snapshot.reviewSha256, entries: [a, b].map(({ id, sha256 }) => ({ id, sha256 })), decision: "approved" as const, actor: "test-approver", note: "Widziałem konkretny zestaw" }; assert.throws(() => decideMediaReview(f.db, f.cfg, { ...input, entries: [input.entries[0]!, { id: b.id, sha256: "wrong" }] })); assert.equal(getAsset(f.db, a.id)?.status, "candidate"); assert.throws(() => decideMediaReview(f.db, f.cfg, { ...input, entries: [input.entries[0]!, input.entries[0]!] })); const approvals = decideMediaReview(f.db, f.cfg, input); assert.equal(approvals.length, 2); assert.ok(approvals.every((ap) => ap.decided_by === "test-approver" && ap.asset_sha256 === f.sha)); assert.throws(() => decideMediaReview(f.db, f.cfg, input), /nieaktualny/); assert.equal(getAsset(f.db, f.master.id)?.status, "approved"); assert.equal(f.db.prepare("SELECT COUNT(*) AS n FROM jobs").get()!.n, 0); }); test("przegląd: zmiana faktu unieważnia decyzję, PDF nie dziedziczy akceptacji", async (t) => { const f = await fixture(); t.after(f.cleanup); const a = f.candidate(); const snapshot = buildMediaReview(f.db, f.cfg, f.p.id); setFact(f.db, f.p.id, { key: "commerce.ean", value: null, status: "unknown", sourceKind: "owner" }); assert.throws(() => decideMediaReview(f.db, f.cfg, { productId: f.p.id, reviewSha256: snapshot.reviewSha256, entries: [{ id: a.id, sha256: a.sha256 }], decision: "approved", actor: "test", note: "OK" }), /nieaktualny/); const pdf = () => registerAsset(f.db, { productId: f.p.id, kind: "pdf", role: "document", sha256: f.sha, mime: "application/pdf", bytes: getAsset(f.db, a.id)!.bytes, path: "image.png", origin: "derived", provenance: { factsSnapshot: { "commerce.ean": "old" } } }); const old = pdf(); decide(f.db, { productId: f.p.id, assetId: old.id, target: "document", scope: "document", decision: "approved", decidedBy: "test" }); const newer = pdf(); const review = buildMediaReview(f.db, f.cfg, f.p.id); assert.equal(review.documents.find((d) => d.id === old.id)?.approved, true); assert.equal(review.documents.find((d) => d.id === newer.id)?.approved, false); assert.ok(review.documents.every((d) => d.blockers.some((b) => /commerce.ean/.test(b)))); }); test("API przeglądu: wymaga sesji i approvera; odczyt nie zapisuje, DTO bez klucza", async (t) => { const f = await fixture(); t.after(f.cleanup); const a = f.candidate(); const app = Fastify(); await app.register(cookie); registerRoutes(app, f.db, f.cfg); t.after(() => app.close()); const url = "/api/products/oskar/media-review"; assert.equal((await app.inject({ url })).statusCode, 401); const cookiesFor = (role: "operator" | "approver") => { createUser(f.db, { login: role, password: "test-isolated-only", displayName: role, role }); return { vilmal_session: login(f.db, role, "test-isolated-only")!.sessionId }; }; const operator = cookiesFor("operator"), approver = cookiesFor("approver"); const before = f.db.prepare("SELECT COUNT(*) AS n FROM approvals").get()!.n; const response = await app.inject({ url, cookies: operator }); assert.equal(response.statusCode, 200); assert.ok(!response.body.includes("TEST-NEVER-SEND")); assert.equal(f.db.prepare("SELECT COUNT(*) AS n FROM approvals").get()!.n, before); const payload = { reviewSha256: response.json().reviewSha256, entries: [{ id: a.id, sha256: a.sha256 }], decision: "approved", note: "jawny przegląd" }; assert.equal((await app.inject({ method: "POST", url: `${url}/decision`, cookies: operator, payload })).statusCode, 403); assert.equal((await app.inject({ method: "POST", url: `${url}/decision`, cookies: approver, payload })).statusCode, 200); assert.equal((await app.inject({ method: "POST", url: `${url}/decision`, cookies: approver, payload })).statusCode, 409); }); test("pre-screening: restart wznawia zapisane wyniki i nie uruchamia dostawcy AI", async (t) => { const f = await fixture(); t.after(f.cleanup); const a = f.candidate(0), b = f.candidate(1); const maskDir = path.join(f.cfg.derivedDir, "masks"); fs.mkdirSync(maskDir, { recursive: true }); await sharp(path.join(f.cfg.mediaDir, "image.png")).ensureAlpha(0.6).png().toFile(path.join(maskDir, `${f.sha}-${process.env.VILMAL_MASK_SESSION ?? "birefnet-general"}.png`)); const job = enqueueJob(f.db, { type: "review_media_batch", provider: "macius-rules", payload: { productId: f.p.id, entries: [a, b].map(({ id, sha256 }) => ({ id, sha256 })), actor: "test" } }); f.db.prepare("UPDATE jobs SET status='running', result_json=? WHERE id=?").run(JSON.stringify({ [a.id]: { verdictId: "already-reviewed" } }), job.id); const worker = startWorker(f.db, f.cfg, null); t.after(worker.stop); const deadline = Date.now() + 10000; while (getJob(f.db, job.id)?.status !== "done" && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 100)); worker.stop(); const result = getJob(f.db, job.id)!; assert.equal(result.status, "done"); const entries = JSON.parse(result.result_json!).entries; assert.equal(entries[a.id].verdictId, "already-reviewed"); assert.ok(entries[b.id].verdictId); assert.equal(result.provider_request_id, null); assert.equal(getAsset(f.db, b.id)?.status, "candidate"); });