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, type Db } 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 { addEans } from "../src/domain/eanPool.js"; import { registerAsset, decide, getAsset } from "../src/domain/assets.js"; import { OFFER_ROLES } from "../src/domain/mediaReview.js"; import { buildOfferContent, saveContentRevision, decideContentRevision } from "../src/domain/offerContent.js"; import { buildOfferPackage, saveOfferSnapshot, decideOfferSnapshot } from "../src/domain/offerPackage.js"; import { buildProductCard } from "../src/domain/productCard.js"; import { buildMissingRenditions } from "../src/domain/renditions.js"; import { grantExportConsent, revokeExportConsent, createExportBatch, retryExportItem, sendExportNow, runExportBatch, exportState, ExportError, } from "../src/domain/exportOutbox.js"; import { disabledBaseAdapter, ExportUncertain, type BaseAdapter } from "../src/integration/baseAdapter.js"; import { registerRoutes } from "../src/routes.js"; import { createUser, login } from "../src/auth.js"; import { getJob, enqueueJob } from "../src/domain/jobs.js"; /** * Fixture z realnie zatwierdzalnym snapshotem paczki: 2 warianty (L/P × * odcień 01), pełna galeria 8 ról z provenance recolor, karta PDF, * zatwierdzona rewizja treści i renditiony base_v1. Droga, ale udowadnia, * że eksport startuje wyłącznie z kompletnej paczki. */ async function fixture(opts: { shades?: string[]; eans?: string[] } = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-export-")); 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 p = createProduct(db, { slug: "exp-sofa", name: "EXP Sofa", kind: "corner-sofa" }); const fabric = upsertFabric(db, { code: "exp", name: "EXPT", producer: "TestTex", 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 opts.shades ?? ["01"]) { upsertShade(db, fabric.id, { code, name: `kolor ${code}`, officialImagePath: "swatch", verification: "confirmed" }); } // pula EAN przed matrycą — warianty dostają przydział przy utworzeniu if (opts.eans) addEans(db, opts.eans); ensureVariantMatrix(db, { productId: p.id, fabricId: fabric.id, sides: ["L", "P"] }); const fact = (key: string, value: unknown) => setFact(db, p.id, { key, value, status: "confirmed", sourceKind: "owner", sourceRef: "test" }); fact("sides.photographed", "P"); fact("fabric.photographed_shade", "01"); fact("dimensions.width_cm", 200); fact("dimensions.depth_cm", 100); fact("dimensions.height_with_cushions_cm", 90); fact("sleeping.width_cm", 120); fact("sleeping.length_cm", 190); fact("features.bedding_container", true); fact("packages.total_weight_kg", 95); fact("commerce.sku_pattern", "VIL-EXP-{L|P}-T{kod}"); fact("commerce.price_pln", 3999); fact("commerce.vat_rate", "zw"); fact("commerce.stock_quantity", 4); fact("commerce.ean_exception", "publikacja bez EAN — decyzja właściciela"); fact("care.instructions", "Czyścić ścierką z wodą i delikatnym detergentem."); fact("gpsr.manufacturer", "Vilmax"); fact("gpsr.responsible_person", "Vilmax"); fact("gpsr.safety_information", "Instrukcja w paczce."); fact("warranty.months", 24); fact("base.inventory_id", 777); fact("base.category_id", 555); fact("base.manufacturer_id", 42); fact("base.warehouse_id", "9"); fact("base.price_group_id", 1); fact("base.variant_model", "parent+variants"); fact("base.allegro_account", "allegro_777"); fact("base.shipping_template_id", "tpl_9"); fact("base.parent_sku", "VIL-EXP-T"); fact("base.allegro_category_id", "12345"); fact("offer.video_exception", "wideo odroczone — osobna zgoda i materiał"); const variants = listVariants(db, p.id); const approve = (assetId: string) => { const a = getAsset(db, assetId)!; decide(db, { productId: p.id, assetId: a.id, variantId: a.variant_id, target: a.role, scope: a.role === "document" ? "document" : a.variant_id ? "variant_media" : "shot", decision: "approved", decidedBy: "test", }); }; const putImage = (role: (typeof OFFER_ROLES)[number] | "blueprint", variantId: string | null = null, parentId: string | null = null, prov: 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: prov }); // 8 masterów + 16 obrazów wariantowych z provenance recolor_v7. const masters = new Map(); for (const role of OFFER_ROLES) { const m = putImage(role); approve(m.id); masters.set(role, m.id); } for (const v of variants) { for (const role of OFFER_ROLES) { const master = getAsset(db, masters.get(role)!)!; const asset = putImage(role, v.id, master.id, { method: "fabric_recolor_v7", baseAsset: { id: master.id, sha256: master.sha256, role }, variant: { side: v.side, shade: v.shade_code, shadeName: `kolor ${v.shade_code}` }, mirrored: v.side === "L", anchorShade: "01", swatches: { target: "swatch", anchor: "swatch" }, }); approve(asset.id); } } const blueprint = putImage("blueprint", null, null, { mode: "ai" }); approve(blueprint.id); const card = await buildProductCard(db, cfg, p.id); if (!("asset" in card)) throw new Error(`fixture: karta nie powstała: ${JSON.stringify(card)}`); approve(card.asset.id); const draft = buildOfferContent(db, p.id); const revision = saveContentRevision(db, { productId: p.id, expectedDraftSha256: draft.draftSha256, edits: [], actor: "test", note: "projekt do oceny" }); decideContentRevision(db, { productId: p.id, revisionId: revision.id, expectedSha256: revision.sha256, decision: "approved", actor: "test-approver", note: "przegląd treści" }); await buildMissingRenditions(db, cfg, p.id); const pack = buildOfferPackage(db, cfg, p.id); assert.deepEqual(pack.blockers, [], `fixture: paczka ma blokery: ${pack.blockers.join(" | ")}`); const saved = saveOfferSnapshot(db, cfg, { productId: p.id, expectedSourceSha256: pack.sourceSha256, expectedSnapshotSha256: pack.snapshotSha256, note: "snapshot eksportowy", actor: "test" }); const approved = decideOfferSnapshot(db, cfg, { productId: p.id, snapshotId: saved.id, expectedSha256: saved.sha256, decision: "approved", note: "paczka kompletna", actor: "test-approver" }); const approverLogin = `exp-app-${Math.random().toString(36).slice(2, 8)}`; const approverUser = { id: createUser(db, { login: approverLogin, password: "test-isolated-only", displayName: "approver", role: "approver" }), login: approverLogin }; const cleanup = () => { db.close(); fs.rmSync(dir, { recursive: true, force: true }); }; return { db, cfg, p, variants, snapshot: approved, approverUser, png, sha, cleanup }; } /** Fake adapter ze sklepem „po drugiej stronie" i rejestrem wywołań. */ function fakeAdapter(behavior: { loseResponseForSku?: string; stripReadback?: Array<"ean" | "images"> } = {}) { const store = new Map>(); const calls: Array<{ sku?: unknown; parent_id?: unknown; product_id?: unknown }> = []; let seq = 9000; const adapter: BaseAdapter = { live: true, name: "fake", addInventoryProduct: (params) => { calls.push({ sku: params.sku, parent_id: params.parent_id, product_id: params.product_id }); // product_id w parametrach = edycja istniejącego rekordu (semantyka Base) const id = typeof params.product_id === "number" ? params.product_id : ++seq; store.set(id, { ...store.get(id), ...params }); if (behavior.loseResponseForSku && params.sku === behavior.loseResponseForSku) { return Promise.reject(new ExportUncertain("timeout po wysłaniu — zapis mógł dojść")); } return Promise.resolve({ productId: id, warnings: [] }); }, getInventoryProductsData: (_inv, ids) => Promise.resolve( ids.filter((id) => store.has(id)).map((id) => { const s = store.get(id)!; const row: Record = { id, sku: s.sku, ean: s.ean ?? null, text_fields: s.text_fields, images: s.images ?? {} }; for (const f of behavior.stripReadback ?? []) { if (f === "ean") row.ean = null; if (f === "images") row.images = {}; } return row; }) ), findProductIdBySku: (_inv, sku) => { for (const [id, params] of store) if (params.sku === sku) return Promise.resolve(id); return Promise.resolve(null); }, getInventories: () => Promise.resolve([{ inventoryId: 777, name: "fake" }]), }; return { adapter, store, calls }; } const jobIdOf = (db: Db, batchId: string) => (db.prepare("SELECT job_id FROM export_batches WHERE id = ?").get(batchId) as { job_id: string }).job_id; test("eksport: zgoda wymaga zatwierdzonego snapshotu o dokładnym SHA i odświeżonych źródeł", async (t) => { const f = await fixture(); t.after(f.cleanup); const base = { productId: f.p.id, actorId: f.approverUser.id }; assert.throws(() => grantExportConsent(f.db, f.cfg, { ...base, snapshotId: f.snapshot.id, expectedSnapshotSha256: "0".repeat(64), note: "zły sha" }), /SHA snapshotu/); const consent = grantExportConsent(f.db, f.cfg, { ...base, snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zapis do katalogu Base dla tego snapshotu" }); assert.equal(consent.active, true); assert.equal(consent.scope.itemCount, 3); assert.equal(consent.scope.inventoryId, 777); assert.equal(consent.scope.target, "base-inventory"); assert.ok(!JSON.stringify(consent).includes("TEST-NEVER-SEND")); // zmiana źródeł po zgodzie → nie odrzuca: snapshot i zgoda naprawiają się // w locie, a wsad wiąże się z nowym, auto-zaakceptowanym snapshotem setFact(f.db, f.p.id, { key: "commerce.stock_quantity", value: 5, status: "confirmed", sourceKind: "owner", sourceRef: "test" }); const healed = createExportBatch(f.db, f.cfg, { ...base, consentId: consent.id }); assert.equal(healed.created, true); const healedPack = buildOfferPackage(f.db, f.cfg, f.p.id); assert.equal(healed.batch.snapshotSha256, healedPack.snapshotSha256); assert.notEqual(healed.batch.snapshotSha256, f.snapshot.sha256); const rebound = f.db.prepare("SELECT snapshot_sha256 FROM export_consents WHERE id = ?").get(consent.id) as { snapshot_sha256: string }; assert.equal(rebound.snapshot_sha256, healedPack.snapshotSha256); }); test("eksport: wsad jest idempotentny, payload bez tokenu i ścieżek lokalnych, mapy prices/stock", async (t) => { const f = await fixture(); t.after(f.cleanup); const base = { productId: f.p.id, actorId: f.approverUser.id }; const consent = grantExportConsent(f.db, f.cfg, { ...base, snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zgoda testowa" }); const first = createExportBatch(f.db, f.cfg, { ...base, consentId: consent.id }); assert.equal(first.created, true); assert.equal(first.batch.items.length, 3); assert.equal(first.batch.status, "queued"); const second = createExportBatch(f.db, f.cfg, { ...base, consentId: consent.id }); assert.equal(second.created, false); assert.equal(second.batch.id, first.batch.id); assert.equal(f.db.prepare("SELECT COUNT(*) n FROM export_items").get()!.n, 3); assert.equal(f.db.prepare("SELECT COUNT(*) n FROM jobs WHERE type = 'export_dispatch'").get()!.n, 1); const parent = first.batch.items.find((i) => i.kind === "parent")!; const variantItems = first.batch.items.filter((i) => i.kind === "variant"); assert.equal(variantItems.length, 2); const payloads = f.db.prepare("SELECT * FROM export_items WHERE batch_id = ? ORDER BY position").all(first.batch.id) as Array<{ kind: string; sku: string | null; payload_json: string }>; const parentPayload = JSON.parse(payloads[0]!.payload_json) as { method: string; parameters: Record }; assert.equal(parentPayload.method, "addInventoryProduct"); assert.equal(parentPayload.parameters.sku, "VIL-EXP-T"); assert.equal(parentPayload.parameters.inventory_id, 777); assert.equal(parentPayload.parameters.parent_id, undefined); assert.equal(parentPayload.parameters.prices, undefined); assert.equal(parentPayload.parameters.stock, undefined); const variantPayload = JSON.parse(payloads[1]!.payload_json) as { parameters: Record }; const vparams = variantPayload.parameters; assert.equal(vparams.parent_id, null); assert.match(String(vparams.sku), /^VIL-EXP-[LP]-T01$/); assert.deepEqual(vparams.prices, { "1": 3999 }); assert.deepEqual(vparams.stock, { bl_9: 4 }); const textFields = vparams.text_fields as Record; assert.equal(typeof textFields["name|pl|allegro_777"], "string"); assert.ok(String(textFields["name|pl|allegro_777"]).includes("EXP Sofa")); assert.equal(textFields["marketplace_category_id|allegro_777"], "12345"); assert.equal(typeof textFields["description|pl|allegro_777"], "string"); assert.deepEqual(vparams.shipping_template, { allegro_777: "tpl_9" }); const images = vparams.images as Array<{ position: number; renditionId: string; sha256: string }>; assert.equal(images.length, 8); assert.ok(images.every((i) => i.renditionId && /^[0-9a-f]{64}$/.test(i.sha256))); // żaden payload nie zawiera sekretów, ścieżek ani bajtów mediów for (const row of payloads) { assert.ok(!row.payload_json.includes("TEST-NEVER-SEND")); assert.ok(!row.payload_json.includes("BASELINKER")); assert.ok(!row.payload_json.includes(f.cfg.mediaDir) && !row.payload_json.includes("image.png")); assert.ok(!row.payload_json.includes("data:")); } assert.equal(f.db.prepare("SELECT COUNT(*) n FROM export_items WHERE status != 'pending'").get()!.n, 0); }); test("eksport: worker wysyła rodzica → warianty z parent_id, read-back potwierdza zapis", async (t) => { const f = await fixture(); t.after(f.cleanup); const consent = grantExportConsent(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zgoda" }); const { batch } = createExportBatch(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, consentId: consent.id }); const fake = fakeAdapter(); await runExportBatch(f.db, f.cfg, fake.adapter, batch.jobId!); const items = f.db.prepare("SELECT * FROM export_items WHERE batch_id = ? ORDER BY position").all(batch.id) as Array<{ kind: string; status: string; external_id: number | null; readback_json: string | null; payload_json: string }>; assert.ok(items.every((i) => i.status === "verified"), JSON.stringify(items.map((i) => [i.kind, i.status]))); const parentExt = items[0]!.external_id!; assert.equal(typeof parentExt, "number"); for (const call of fake.calls.slice(1)) assert.equal(call.parent_id, parentExt); // zdjęcia wysłane jako mapa data: dopiero na „drut", nie w DB const sentParams = fake.store.get(parentExt)!; assert.equal(sentParams.sku, "VIL-EXP-T"); const variantSent = [...fake.store.values()].find((s) => s.sku === "VIL-EXP-L-T01")!; assert.deepEqual(Object.keys(variantSent.images as object).sort(), ["0", "1", "2", "3", "4", "5", "6", "7"]); assert.match((variantSent.images as Record)["0"], /^data:/); for (const i of items) assert.ok(i.readback_json?.includes(String(i.external_id))); const job = getJob(f.db, batch.jobId!)!; assert.equal(job.status, "done"); const batchRow = f.db.prepare("SELECT status FROM export_batches WHERE id = ?").get(batch.id) as { status: string }; assert.equal(batchRow.status, "done"); }); test("eksport: timeout po wysłaniu → unconfirmed → pojednanie po SKU bez duplikatu", async (t) => { const f = await fixture(); t.after(f.cleanup); const consent = grantExportConsent(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zgoda" }); const { batch } = createExportBatch(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, consentId: consent.id }); // odpowiedź pierwszego wariantu ginie po zapisie po stronie „Base" const behavior: { loseResponseForSku?: string } = { loseResponseForSku: "VIL-EXP-P-T01" }; const fake = fakeAdapter(behavior); await runExportBatch(f.db, f.cfg, fake.adapter, batch.jobId!); const lost = f.db.prepare("SELECT * FROM export_items WHERE batch_id = ? AND sku = 'VIL-EXP-P-T01'").get(batch.id) as { status: string; external_id: number | null }; assert.equal(lost.status, "unconfirmed"); assert.equal(lost.external_id, null); const job = getJob(f.db, batch.jobId!)!; assert.equal(job.status, "queued"); // retryable — wsad wraca do pojednania // drugi przebieg: pojednanie znajduje produkt po SKU, bez ponownego create const callsBefore = fake.calls.filter((c) => c.sku === "VIL-EXP-P-T01").length; behavior.loseResponseForSku = undefined; await runExportBatch(f.db, f.cfg, fake.adapter, batch.jobId!); const reconciled = f.db.prepare("SELECT * FROM export_items WHERE batch_id = ? AND sku = 'VIL-EXP-P-T01'").get(batch.id) as { status: string; external_id: number | null }; assert.equal(reconciled.status, "verified"); assert.equal(typeof reconciled.external_id, "number"); assert.equal(fake.calls.filter((c) => c.sku === "VIL-EXP-P-T01").length, callsBefore); const items = f.db.prepare("SELECT status FROM export_items WHERE batch_id = ?").all(batch.id) as Array<{ status: string }>; assert.ok(items.every((i) => i.status === "verified")); assert.equal((f.db.prepare("SELECT status FROM export_batches WHERE id = ?").get(batch.id) as { status: string }).status, "done"); }); test("eksport: wyłączony adapter nie wysyła niczego; pozycje można ponowić po konfiguracji", async (t) => { const f = await fixture(); t.after(f.cleanup); const consent = grantExportConsent(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zgoda" }); const { batch } = createExportBatch(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, consentId: consent.id }); await runExportBatch(f.db, f.cfg, disabledBaseAdapter, batch.jobId!); const items = f.db.prepare("SELECT * FROM export_items WHERE batch_id = ? ORDER BY position").all(batch.id) as Array<{ kind: string; status: string; last_error: string | null }>; assert.equal(items[0]!.status, "failed"); assert.match(items[0]!.last_error!, /nie jest skonfigurowany/); assert.ok(items.slice(1).every((i) => i.status === "pending")); assert.equal((f.db.prepare("SELECT status FROM export_batches WHERE id = ?").get(batch.id) as { status: string }).status, "partial"); // retry pozycji po „włączeniu" adaptera — nowy job wsadu const retried = retryExportItem(f.db, { productId: f.p.id, itemId: batch.items[0]!.id, actorId: f.approverUser.id, note: "adapter skonfigurowany" }); assert.equal(retried.status, "pending"); const newJobId = jobIdOf(f.db, batch.id); assert.notEqual(newJobId, batch.jobId); const fake = fakeAdapter(); await runExportBatch(f.db, f.cfg, fake.adapter, newJobId); const done = f.db.prepare("SELECT status FROM export_items WHERE batch_id = ?").all(batch.id) as Array<{ status: string }>; assert.ok(done.every((i) => i.status === "verified")); }); test("eksport: odwołanie zgody anuluje niewysłane pozycje i blokuje dispatch", async (t) => { const f = await fixture(); t.after(f.cleanup); const consent = grantExportConsent(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zgoda" }); const { batch } = createExportBatch(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, consentId: consent.id }); const revoked = revokeExportConsent(f.db, { productId: f.p.id, consentId: consent.id, actorId: f.approverUser.id, note: "wycofuję zakres" }); assert.equal(revoked.active, false); const items = f.db.prepare("SELECT status FROM export_items WHERE batch_id = ?").all(batch.id) as Array<{ status: string }>; assert.ok(items.every((i) => i.status === "cancelled")); assert.equal((f.db.prepare("SELECT status FROM export_batches WHERE id = ?").get(batch.id) as { status: string }).status, "cancelled"); // wsad nie wyśle niczego nawet po uruchomieniu starego joba const fake = fakeAdapter(); await runExportBatch(f.db, f.cfg, fake.adapter, batch.jobId!); assert.equal(fake.calls.length, 0); assert.throws(() => createExportBatch(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, consentId: consent.id }), /odwołana/); assert.throws(() => retryExportItem(f.db, { productId: f.p.id, itemId: batch.items[0]!.id, actorId: f.approverUser.id, note: "próba" }), /odwołana/); }); test("eksport: read-back mismatch oznacza pozycję jako unconfirmed, nie verified", async (t) => { const f = await fixture(); t.after(f.cleanup); const consent = grantExportConsent(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zgoda" }); const { batch } = createExportBatch(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, consentId: consent.id }); const fake = fakeAdapter(); // uszkodzony read-back dla rodzica — zwraca cudzy SKU const origRead = fake.adapter.getInventoryProductsData; fake.adapter = { ...fake.adapter, getInventoryProductsData: (inv, ids) => origRead(inv, ids).then((rows) => rows.map((r) => ({ ...r, sku: "WRONG-SKU" }))), }; await runExportBatch(f.db, f.cfg, fake.adapter, batch.jobId!); const parent = f.db.prepare("SELECT status, last_error FROM export_items WHERE batch_id = ? AND kind = 'parent'").get(batch.id) as { status: string; last_error: string }; assert.equal(parent.status, "unconfirmed"); assert.match(parent.last_error, /Read-back/); // warianty nie ruszyły — bez zweryfikowanego rodzica nie ma parent_id assert.ok((f.db.prepare("SELECT status FROM export_items WHERE batch_id = ? AND kind = 'variant'").all(batch.id) as Array<{ status: string }>).every((i) => i.status === "pending")); }); test("eksport: ponowny dispatch pojedna pozycję unconfirmed po SKU (verified/cancelled nietknięte)", async (t) => { const f = await fixture(); t.after(f.cleanup); const consent = grantExportConsent(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zgoda" }); const { batch } = createExportBatch(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, consentId: consent.id }); // przebieg 1: zapis rodzica dochodzi do „Base", ale read-back jest // uszkodzony (bug product_ids) → rodzic unconfirmed, warianty stoją const fake = fakeAdapter(); const origRead = fake.adapter.getInventoryProductsData; fake.adapter = { ...fake.adapter, getInventoryProductsData: () => Promise.resolve([]) }; await runExportBatch(f.db, f.cfg, fake.adapter, batch.jobId!); const broken = f.db.prepare("SELECT status, external_id FROM export_items WHERE batch_id = ? AND kind = 'parent'").get(batch.id) as { status: string; external_id: number | null }; assert.equal(broken.status, "unconfirmed"); assert.equal(typeof broken.external_id, "number"); // produkt realnie istnieje po stronie API assert.equal((f.db.prepare("SELECT status FROM export_batches WHERE id = ?").get(batch.id) as { status: string }).status, "partial"); // nowy job dispatchu (ścieżka retry/resume) — bez ręcznego przestawiania pozycji const job2 = enqueueJob(f.db, { type: "export_dispatch", payload: { batchId: batch.id, productId: f.p.id }, idempotencyKey: `export:${batch.id}:resume:test`, maxAttempts: 5 }); f.db.prepare("UPDATE export_batches SET job_id = ? WHERE id = ?").run(job2.id, batch.id); // naprawiony adapter → przebieg 2: runner pojedna rodzica po SKU // (bez ponownego create/update — write już doszedł) i zweryfikuje // odczytem; warianty ruszają za zweryfikowanym rodzicem fake.adapter = { ...fake.adapter, getInventoryProductsData: origRead }; await runExportBatch(f.db, f.cfg, fake.adapter, job2.id); const rows = f.db.prepare("SELECT kind, status, last_error FROM export_items WHERE batch_id = ? ORDER BY position").all(batch.id) as Array<{ kind: string; status: string; last_error: string | null }>; assert.ok(rows.every((r) => r.status === "verified" && r.last_error === null), JSON.stringify(rows)); const parentCalls = fake.calls.filter((c) => c.sku === "VIL-EXP-T"); assert.equal(parentCalls.length, 1); // utworzony raz; pojednanie nie wysyła ponownie assert.equal([...fake.store.values()].filter((p) => p.sku === "VIL-EXP-T").length, 1); // bez duplikatu w „Base" assert.equal((f.db.prepare("SELECT status FROM export_batches WHERE id = ?").get(batch.id) as { status: string }).status, "done"); assert.equal(getJob(f.db, job2.id)!.status, "done"); }); test("eksport: SKU istniejący w inwentarzu dostaje update (product_id), nie duplikat", async (t) => { const f = await fixture(); t.after(f.cleanup); const consent = grantExportConsent(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zgoda" }); const { batch } = createExportBatch(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, consentId: consent.id }); const fake = fakeAdapter(); // „Base" już zawiera produkty pod SKU wsadu — jak po wcześniejszym eksporcie fake.store.set(555001, { sku: "VIL-EXP-T", text_fields: { name: "stary rodzic" } }); fake.store.set(555002, { sku: "VIL-EXP-L-T01", parent_id: 555001, text_fields: { name: "stary wariant" } }); const storeSizeBefore = fake.store.size; await runExportBatch(f.db, f.cfg, fake.adapter, batch.jobId!); const rows = f.db.prepare("SELECT kind, sku, status, external_id FROM export_items WHERE batch_id = ? ORDER BY position").all(batch.id) as Array<{ kind: string; sku: string; status: string; external_id: number | null }>; assert.ok(rows.every((r) => r.status === "verified"), JSON.stringify(rows)); // rodzic i istniejący wariant → update na dotychczasowych ID, nie nowe rekordy assert.equal(rows.find((r) => r.kind === "parent")!.external_id, 555001); assert.equal(rows.find((r) => r.sku === "VIL-EXP-L-T01")!.external_id, 555002); // drugi wariant nie istniał → create z parent_id pojednanego rodzica const createdVariant = fake.calls.find((c) => c.sku === "VIL-EXP-P-T01")!; assert.equal(createdVariant.product_id, undefined); assert.equal(createdVariant.parent_id, 555001); // zero duplikatów: sklep urósł tylko o jeden nowy produkt assert.equal(fake.store.size, storeSizeBefore + 1); assert.equal([...fake.store.values()].filter((p) => p.sku === "VIL-EXP-T").length, 1); assert.equal([...fake.store.values()].filter((p) => p.sku === "VIL-EXP-L-T01").length, 1); }); test("eksport API: sesja, rola approvera, CSRF i brak tokenu w odpowiedziach", 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: "exp-operator", password: "test-isolated-only", displayName: "op", role: "operator" }); const operator = { vilmal_session: login(f.db, "exp-operator", "test-isolated-only")!.sessionId }; const approver = { vilmal_session: login(f.db, f.approverUser.login, "test-isolated-only")!.sessionId }; const base = `/api/products/exp-sofa/export`; assert.equal((await app.inject({ url: base })).statusCode, 401); const state = await app.inject({ url: base, cookies: operator }); assert.equal(state.statusCode, 200); assert.equal(state.json().adapter.configured, false); assert.ok(!state.body.includes("TEST-NEVER-SEND") && !state.body.includes("BASELINKER")); const consentBody = { snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zgoda operatora" }; assert.equal((await app.inject({ method: "POST", url: `${base}/consents`, cookies: operator, payload: consentBody })).statusCode, 403); assert.equal((await app.inject({ method: "POST", url: `${base}/consents`, cookies: approver, headers: { origin: "https://evil.example" }, payload: consentBody })).statusCode, 403); const granted = await app.inject({ method: "POST", url: `${base}/consents`, cookies: approver, payload: consentBody }); assert.equal(granted.statusCode, 200); const consentId = granted.json().consent.id; assert.equal((await app.inject({ method: "POST", url: `${base}/dispatch`, cookies: operator, payload: { consentId } })).statusCode, 403); const dispatched = await app.inject({ method: "POST", url: `${base}/dispatch`, cookies: approver, payload: { consentId } }); assert.equal(dispatched.statusCode, 200); assert.equal(dispatched.json().batch.items.length, 3); // podgląd kontraktu pozycji — bez tokenu i ścieżek const item = dispatched.json().batch.items[0]; const shown = await app.inject({ url: `${base}/items/${item.id}`, cookies: operator }); assert.equal(shown.statusCode, 200); assert.equal(shown.json().payload.method, "addInventoryProduct"); assert.ok(!shown.body.includes("TEST-NEVER-SEND")); assert.equal((await app.inject({ method: "POST", url: `${base}/items/${item.id}/retry`, cookies: operator, payload: { note: "próba" } })).statusCode, 403); // pozycja pending nie podlega retry assert.equal((await app.inject({ method: "POST", url: `${base}/items/${item.id}/retry`, cookies: approver, payload: { note: "próba" } })).statusCode, 409); // odwołanie przez API anuluje wsad const revoked = await app.inject({ method: "POST", url: `${base}/consents/${consentId}/revoke`, cookies: approver, payload: { note: "koniec testu" } }); assert.equal(revoked.statusCode, 200); assert.equal(revoked.json().consent.active, false); }); test("eksport: stan outboxu raportuje adapter disabled bez sekretów", async (t) => { const f = await fixture(); t.after(f.cleanup); const state = exportState(f.db, f.p.id, disabledBaseAdapter); assert.equal(state.adapter.configured, false); assert.equal(state.adapter.name, "disabled"); assert.equal(state.consents.length, 0); assert.equal(state.batches.length, 0); assert.ok(!JSON.stringify(state).includes("TEST-NEVER-SEND")); }); test("eksport: sendExportNow — jedno kliknięcie bez snapshotu, zgody i SHA; resend bez duplikatów", async (t) => { const f = await fixture(); t.after(f.cleanup); const base = { productId: f.p.id, actorId: f.approverUser.id }; // zero ręcznych kroków: snapshot, akceptacja, zgoda i wsad powstają w locie const res = sendExportNow(f.db, f.cfg, base); assert.equal(res.created, true); assert.equal(res.batch.items.length, 3); const consent = f.db.prepare("SELECT * FROM export_consents WHERE id = ?").get(res.batch.consentId) as { snapshot_sha256: string; revoked_at: string | null }; assert.equal(consent.snapshot_sha256, res.batch.snapshotSha256); assert.equal(consent.revoked_at, null); const decision = f.db.prepare("SELECT decision FROM offer_snapshot_decisions WHERE snapshot_id = ? ORDER BY rowid DESC LIMIT 1").get(res.batch.snapshotId) as { decision: string }; assert.equal(decision.decision, "approved"); const fake = fakeAdapter(); await runExportBatch(f.db, f.cfg, fake.adapter, res.batch.jobId!); const items = f.db.prepare("SELECT status FROM export_items WHERE batch_id = ?").all(res.batch.id) as Array<{ status: string }>; assert.ok(items.every((i) => i.status === "verified")); assert.equal(fake.calls.length, 3); // ponowne kliknięcie tego samego stanu — nic nie wysyła const again = sendExportNow(f.db, f.cfg, base); assert.equal(again.alreadySent, true); assert.equal(f.db.prepare("SELECT COUNT(*) n FROM export_batches").get()!.n, 1); // zmiana danych → nowy snapshot/wsad, ale SKU już istnieją w „Base" — // runner pojedna je zamiast tworzyć duplikaty setFact(f.db, f.p.id, { key: "commerce.stock_quantity", value: 7, status: "confirmed", sourceKind: "owner", sourceRef: "test" }); const resend = sendExportNow(f.db, f.cfg, base); assert.equal(resend.created, true); assert.notEqual(resend.batch.id, res.batch.id); await runExportBatch(f.db, f.cfg, fake.adapter, resend.batch.jobId!); // zero nowych create — SKU pojednane i odświeżone updatem (product_id) assert.equal(fake.calls.length, 6); const resent = fake.calls.slice(3); assert.ok(resent.every((c) => typeof c.product_id === "number")); const done = f.db.prepare("SELECT status FROM export_items WHERE batch_id = ?").all(resend.batch.id) as Array<{ status: string }>; assert.ok(done.every((i) => i.status === "verified")); }); test("eksport: błąd kontraktu (brak SKU wariantu) przerywa wsad przed zapisem", async (t) => { const f = await fixture(); t.after(f.cleanup); // snapshot już zatwierdzony; uszkadzamy payload wariantu w DB — symulacja // stanu, którego walidacja translatora ma nie przepuścić const consent = grantExportConsent(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zgoda" }); const { batch } = createExportBatch(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, consentId: consent.id }); assert.equal(batch.items.length, 3); // rodzic bez external_id → warianty czekają; pozycja rodzica failed → reszta pending const fake = fakeAdapter(); fake.adapter = { ...fake.adapter, addInventoryProduct: () => Promise.reject(new Error("zwykły błąd sieci — ale traktujemy jako uncertain nie")) }; await runExportBatch(f.db, f.cfg, fake.adapter, batch.jobId!); const rows = f.db.prepare("SELECT kind, status FROM export_items WHERE batch_id = ? ORDER BY position").all(batch.id) as Array<{ kind: string; status: string }>; assert.equal(rows[0]!.status, "failed"); assert.ok(rows.slice(1).every((r) => r.status === "pending")); }); test("eksport: EAN wariantu z ean_pool trafia wprost do pola ean w addInventoryProduct", async (t) => { const eans = ["5901234000001", "5901234000002", "5901234000003"]; const f = await fixture({ eans }); t.after(f.cleanup); // przydział FIFO przy tworzeniu matrycy: 2 warianty (L/01, P/01) const assigned = f.db.prepare("SELECT ean, assigned_variant_id FROM ean_pool WHERE status = 'assigned' ORDER BY id").all() as Array<{ ean: string; assigned_variant_id: string }>; assert.equal(assigned.length, 2); assert.equal((f.db.prepare("SELECT COUNT(*) n FROM ean_pool WHERE status = 'available'").get() as { n: number }).n, 1); // podgląd paczki: każdy wariant ma swój EAN, nie fakt produktu const pack = buildOfferPackage(f.db, f.cfg, f.p.id); const previewEans = pack.base.variants.map((v) => v.parameters.ean); assert.deepEqual([...previewEans].sort(), [...eans.slice(0, 2)].sort()); for (const v of pack.base.variants) { const row = assigned.find((a) => a.assigned_variant_id === v.variantId)!; assert.equal(v.parameters.ean, row.ean, `wariant ${v.side}/${v.shade} ma EAN inny niż przydział puli`); } // rodzic bez commerce.ean → brak pola ean assert.equal(pack.base.parent.parameters.ean, null); // pełny tor: snapshot → wsad → addInventoryProduct — ean na „drucie" const consent = grantExportConsent(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zgoda" }); const { batch } = createExportBatch(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, consentId: consent.id }); const fake = fakeAdapter(); await runExportBatch(f.db, f.cfg, fake.adapter, batch.jobId!); const sentVariants = [...fake.store.values()].filter((s) => s.parent_id); assert.equal(sentVariants.length, 2); const sentEans = sentVariants.map((s) => s.ean).sort(); assert.deepEqual(sentEans, [...eans.slice(0, 2)].sort()); // każdy wariant dostał dokładnie swój przydział (powiązanie przez pozycję wsadu) const items = f.db.prepare("SELECT variant_id, payload_json FROM export_items WHERE batch_id = ? AND kind = 'variant'").all(batch.id) as Array<{ variant_id: string; payload_json: string }>; for (const item of items) { const expected = assigned.find((a) => a.assigned_variant_id === item.variant_id)!.ean; assert.equal((JSON.parse(item.payload_json).parameters as Record).ean, expected); } }); test("eksport: wariant bez przydziału EAN wysyła się bez pola ean", async (t) => { const f = await fixture(); t.after(f.cleanup); // pula pusta, fakt commerce.ean brak const pack = buildOfferPackage(f.db, f.cfg, f.p.id); for (const v of pack.base.variants) assert.equal(v.parameters.ean, null); const consent = grantExportConsent(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zgoda" }); const { batch } = createExportBatch(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, consentId: consent.id }); const fake = fakeAdapter(); await runExportBatch(f.db, f.cfg, fake.adapter, batch.jobId!); const sentVariants = [...fake.store.values()].filter((s) => s.parent_id); assert.equal(sentVariants.length, 2); for (const s of sentVariants) assert.equal(s.ean, undefined, "null ean nie może wyciec do Base — baseParams pomija null"); }); test("eksport: read-back odrzuca pustą galerię i brak/cudzy EAN mimo zgodnego SKU", async (t) => { const eans = ["5901234000001", "5901234000002"]; const f = await fixture({ eans }); t.after(f.cleanup); const consent = grantExportConsent(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, snapshotId: f.snapshot.id, expectedSnapshotSha256: f.snapshot.sha256, note: "zgoda" }); const { batch } = createExportBatch(f.db, f.cfg, { productId: f.p.id, actorId: f.approverUser.id, consentId: consent.id }); const behavior: { stripReadback?: Array<"ean" | "images"> } = { stripReadback: ["images"] }; const fake = fakeAdapter(behavior); const items = () => f.db.prepare("SELECT kind, status, last_error FROM export_items WHERE batch_id = ? ORDER BY position").all(batch.id) as Array<{ kind: string; status: string; last_error: string | null }>; const resume = () => { const job = enqueueJob(f.db, { type: "export_dispatch", payload: { batchId: batch.id, productId: f.p.id }, idempotencyKey: `export:${batch.id}:resume:${Math.random().toString(36).slice(2)}`, maxAttempts: 5 }); f.db.prepare("UPDATE export_batches SET job_id = ? WHERE id = ?").run(job.id, batch.id); return job.id; }; // przebieg 1: odczyt bez zdjęć → warianty unconfirmed, rodzic (bez galerii) verified await runExportBatch(f.db, f.cfg, fake.adapter, batch.jobId!); let rows = items(); assert.equal(rows[0]!.status, "verified", "rodzic nie wysyła galerii — kontrola zdjęć go nie dotyczy"); assert.ok(rows.slice(1).every((r) => r.status === "unconfirmed" && /zdjęć/.test(r.last_error!)), JSON.stringify(rows)); // przebieg 2: odczyt bez EAN → unconfirmed z powodem EAN behavior.stripReadback = ["ean"]; await runExportBatch(f.db, f.cfg, fake.adapter, resume()); rows = items(); assert.ok(rows.slice(1).every((r) => r.status === "unconfirmed" && /EAN/.test(r.last_error!)), JSON.stringify(rows)); // przebieg 3: kompletny odczyt → wszystko verified, batch done behavior.stripReadback = undefined; await runExportBatch(f.db, f.cfg, fake.adapter, resume()); rows = items(); assert.ok(rows.every((r) => r.status === "verified"), JSON.stringify(rows)); assert.equal((f.db.prepare("SELECT status FROM export_batches WHERE id = ?").get(batch.id) as { status: string }).status, "done"); });