Files
till 50dfca59e1 v2.4: Medienbibliothek+WebP, Varianten-Matrix, Litestream-Backups, intelligentere Analytics
P1 Medien: eigener Admin-Bereich /admin/medien (Grid, Mehrfach-Upload, Drag&Drop, Alt-Text, URL kopieren, Loeschen). Upload konvertiert JPG/PNG via sharp zu WebP (Qualitaet 82, max 2000px), Original wird verworfen; WebP/SVG/GIF/AVIF unveraendert; Konvertierungsfehler -> Original behalten statt 500. media um alt/width/height erweitert. Wiederverwendbarer Medien-Picker (public/media-picker.js) ersetzt den URL-Prompt im Block-Editor, Produkt-Editor (Karte/Galerie/Varianten-Bild), Slides und Popups. JSON-Quelle /api/admin/media (session-gesichert).

P2 Varianten: products.options_json + Tabelle product_variants. Produkt-Editor mit Options-Definition + Matrix-Generator (Preis-Override/Bestand/SKU/Bild/aktiv je Variante). PDP-Selektoren -> Variante; Cart/Checkout tragen sku+Options, Order-Item bekommt sku/variant, Variantenpreis serverseitig verifiziert. Produkte ohne Optionen unveraendert.

P3 Litestream: Binary im Dockerfile, docker-entrypoint.sh (Restore+replicate nur bei LITESTREAM_REPLICA_URL, sonst reiner Node-Start), litestream.yml, Backup-Status unter Einstellungen, README + .env.example.

P4 Analytics: Bestseller, Top-Suchbegriffe, Umsatz/Quelle, Umsatz-Zeitreihe, AOV, Wiederkaufrate, Lager-Warnungen.

Neue Dep sharp. +19 Unit-Tests (49 gesamt gruen), Build + Smoke (P1-P4) gruen.
2026-06-18 08:09:57 +00:00

124 lines
9.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// hd-commerce — Unit-Tests für Shop-Mathematik + Sanitizer. Lauf: npm test
import assert from 'node:assert';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
process.env.DB_PATH = join(mkdtempSync(join(tmpdir(), 'hdc-test-')), 't.db');
const store = await import('../src/lib/store-sqlite.js');
const { sanitizeHtml } = await import('../src/lib/sanitize.js');
let pass = 0, fail = 0;
const t = (name, fn) => { try { fn(); pass++; console.log(' ✓', name); } catch (e) { fail++; console.error(' ✗', name, '—', e.message); } };
t('taxFromGross 19% aus 119,00 € = 19,00 €', () => assert.strictEqual(store.taxFromGross(11900, 19), 1900));
t('taxFromGross 7% aus 107,00 € = 7,00 €', () => assert.strictEqual(store.taxFromGross(10700, 7), 700));
t('taxFromGross 0% = 0', () => assert.strictEqual(store.taxFromGross(5000, 0), 0));
t('Versand DE gratis ab 49 € (50 € → 0)', () => assert.strictEqual(store.shippingFor('DE', 5000).price_cents, 0));
t('Versand DE unter Schwelle (10 € → 4,90 €)', () => assert.strictEqual(store.shippingFor('DE', 1000).price_cents, 490));
t('Versand CH = 9,90 €', () => assert.strictEqual(store.shippingFor('CH', 5000).price_cents, 990));
t('Gutschein WILLKOMMEN10 = 10% (50 € → 5 €)', () => { const v = store.validateDiscount('WILLKOMMEN10', 5000); assert.ok(v.ok); assert.strictEqual(v.amountCents, 500); });
t('Gutschein NAEHEN5 = 5% (50 € → 2,50 €)', () => { const v = store.validateDiscount('NAEHEN5', 5000); assert.ok(v.ok); assert.strictEqual(v.amountCents, 250); });
t('Gutschein case-insensitiv (willkommen10)', () => assert.ok(store.validateDiscount('willkommen10', 5000).ok));
t('Gutschein unbekannt → ungültig', () => assert.ok(!store.validateDiscount('GIBTSNICHT', 5000).ok));
t('Sanitizer entfernt <script>', () => assert.ok(!/<script/i.test(sanitizeHtml('<p>x</p><script>alert(1)</script>'))));
t('Sanitizer entfernt onerror=', () => assert.ok(!/onerror/i.test(sanitizeHtml('<img src=x onerror=alert(1)>'))));
t('Sanitizer neutralisiert javascript:', () => assert.ok(!/javascript:/i.test(sanitizeHtml('<a href="javascript:alert(1)">x</a>'))));
t('Sanitizer lässt normales Markup', () => assert.ok(/<strong>/.test(sanitizeHtml('<strong>fett</strong>'))));
// --- v2.3 Feature-Module ---
// Suche: Treffer + leerer Query
{
const id = store.createProduct({ name: 'Blaues Wollknäuel Merino', shortName: 'Wollknäuel', priceCents: 990, category: 'Garne', desc: 'Weiche Merinowolle in Blau', mwst: 19 });
t('Suche findet Produkt nach Name (woll)', () => { const r = store.searchProducts('woll'); assert.ok(r.some(p => p.id === id)); });
t('Suche case-insensitiv (MERINO)', () => assert.ok(store.searchProducts('MERINO').some(p => p.id === id)));
t('Suche findet nach Kategorie (garne)', () => assert.ok(store.searchProducts('garne').some(p => p.id === id)));
t('Suche leerer Query → keine Treffer', () => assert.strictEqual(store.searchProducts('').length, 0));
t('Suche ohne Treffer → leeres Array', () => assert.strictEqual(store.searchProducts('xyzgibtsnicht123').length, 0));
}
// Bewertungen: Durchschnitt nur über freigegebene
{
const slug = 'test-review-produkt';
const r1 = store.addReview({ product_slug: slug, name: 'A', rating: 5, text: 'top' });
const r2 = store.addReview({ product_slug: slug, name: 'B', rating: 3, text: 'ok' });
store.addReview({ product_slug: slug, name: 'C', rating: 1, text: 'nicht freigegeben' });
store.setReviewApproved(r1.id, 1);
store.setReviewApproved(r2.id, 1);
t('Review-Average = 4.0 (5+3)/2, nur freigegebene', () => { const s = store.reviewSummary(slug); assert.strictEqual(s.count, 2); assert.strictEqual(s.average, 4); });
t('Review-Rating ausserhalb 15 → Fehler', () => assert.ok(!store.addReview({ product_slug: slug, name: 'D', rating: 9 }).ok));
t('addReview ohne Rating → Fehler', () => assert.ok(!store.addReview({ product_slug: slug, rating: 0 }).ok));
}
// Kundenkonten: Registrierung + Passwort-Prüfung (getrennte Auth)
{
const reg = store.registerCustomer({ name: 'Test Kunde', email: 'kunde@example.com', password: 'geheim123' });
t('Kunde registrieren', () => assert.ok(reg.ok && reg.id));
t('Kunde Login korrekt', () => assert.ok(store.verifyCustomer('kunde@example.com', 'geheim123')));
t('Kunde Login falsches Passwort → null', () => assert.strictEqual(store.verifyCustomer('kunde@example.com', 'falsch'), null));
t('Kurzes Passwort → Fehler', () => assert.ok(!store.registerCustomer({ email: 'x@y.de', password: '123' }).ok));
}
// Abandoned-Cart: erfassen, fällig, recovered
{
const cap = store.captureAbandonedCart({ email: 'cart@example.com', items: [{ name: 'X', priceCents: 1000, qty: 2 }], total_cents: 2000 });
t('Abandoned-Cart erfassen', () => assert.ok(cap.ok && cap.id));
t('Abandoned-Cart Stats zählt offen', () => assert.ok(store.abandonedCartStats().open >= 1));
t('recovered markieren per E-Mail', () => { const n = store.markCartRecoveredByEmail('cart@example.com'); assert.ok(n >= 1); });
t('recovered Karte nicht mehr fällig', () => { const due = store.dueAbandonedCarts(0); assert.ok(!due.some(c => c.email === 'cart@example.com')); });
}
// --- v2.4: Varianten-Matrix ---
{
const combos = store.variantCombinations([{ name: 'Größe', values: ['S', 'M', 'L'] }, { name: 'Farbe', values: ['Blau', 'Rot'] }]);
t('variantCombinations: 3×2 = 6 Kombinationen', () => assert.strictEqual(combos.length, 6));
t('variantCombinations enthält {Größe:M,Farbe:Rot}', () => assert.ok(combos.some(c => c['Größe'] === 'M' && c['Farbe'] === 'Rot')));
t('variantCombinations: leere Optionen → []', () => assert.strictEqual(store.variantCombinations([]).length, 0));
t('variantCombinations: Option ohne Werte ignoriert', () => assert.strictEqual(store.variantCombinations([{ name: 'X', values: [] }]).length, 0));
const pid = store.createProduct({ name: 'Varianten-Shirt', priceCents: 2000, category: 'Test', mwst: 19, options: [{ name: 'Größe', values: ['S', 'M'] }] });
store.setProductVariants(pid, [
{ options: { 'Größe': 'S' }, sku: 'SH-S', price_cents: null, stock: 3, active: true },
{ options: { 'Größe': 'M' }, sku: 'SH-M', price_cents: 2500, stock: 0, active: true },
]);
const vs = store.listVariants(pid);
t('setProductVariants legt 2 Varianten an', () => assert.strictEqual(vs.length, 2));
t('Variante per SKU auflösbar', () => { const v = store.getVariantBySku('SH-M'); assert.ok(v && v.options['Größe'] === 'M' && v.price_cents === 2500); });
t('Variante mit price_cents=null = Override leer', () => { const v = store.getVariantBySku('SH-S'); assert.strictEqual(v.price_cents, null); });
store.setProductVariants(pid, [{ options: { 'Größe': 'S' }, sku: 'SH-S2', stock: 9, active: true }]);
t('setProductVariants ersetzt Matrix atomar (jetzt 1)', () => assert.strictEqual(store.listVariants(pid).length, 1));
t('Produkt ohne Optionen weiter normal nutzbar', () => { const np = store.createProduct({ name: 'Simpel', priceCents: 500 }); const p = store.getProductById(np); assert.ok(p && p.priceCents === 500 && Array.isArray(p.options) && p.options.length === 0); });
}
// --- v2.4: Analytics (Conversion / AOV / Wiederkaufrate) ---
{
// Drei pageviews (zwei Sessions), zwei Käufe.
store.recordEvent({ type: 'pageview', session: 'sess-a' });
store.recordEvent({ type: 'pageview', session: 'sess-b' });
store.recordEvent({ type: 'search', path: '/suche', meta: { q: 'wolle', results: 4 } });
store.recordEvent({ type: 'search', path: '/suche', meta: { q: 'wolle', results: 4 } });
store.recordEvent({ type: 'search', path: '/suche', meta: { q: 'gibtsnicht', results: 0 } });
const a = store.analyticsSummary(30);
t('analyticsSummary: AOV = Umsatz/Käufe', () => { if (a.purchases > 0) assert.strictEqual(Math.round(a.aov), Math.round(a.revenue / a.purchases)); else assert.strictEqual(a.aov, 0); });
t('analyticsSummary: Conversion in Prozent plausibel', () => assert.ok(a.conversion >= 0 && a.conversion <= 100));
t('analyticsSummary: repeatRate vorhanden (0100)', () => assert.ok(a.repeatRate >= 0 && a.repeatRate <= 100));
t('analyticsSummary: Top-Suchbegriff „wolle" mit 2 Suchen', () => { const w = a.topSearches.find(x => x.term === 'wolle'); assert.ok(w && w.hits === 2); });
t('analyticsSummary: „gibtsnicht" als Null-Treffer markiert', () => { const z = a.topSearches.find(x => x.term === 'gibtsnicht'); assert.ok(z && z.zero === 1); });
t('analyticsSummary: stockWarnings ist Array', () => assert.ok(Array.isArray(a.stockWarnings)));
t('analyticsSummary: bestsellers ist Array', () => assert.ok(Array.isArray(a.bestsellers)));
}
// --- v2.4: Medien (Alt-Text + Löschen) ---
{
const mid = store.addMedia({ filename: 'x.webp', url: '/uploads/x.webp', mime: 'image/webp', size: 1234, width: 800, height: 600 });
t('addMedia speichert Maße', () => { const m = store.getMediaById(mid); assert.ok(m && m.width === 800 && m.height === 600); });
t('updateMediaAlt setzt Alt-Text', () => { store.updateMediaAlt(mid, 'Ein Bild'); assert.strictEqual(store.getMediaById(mid).alt, 'Ein Bild'); });
t('deleteMedia entfernt Datensatz', () => { store.deleteMedia(mid); assert.strictEqual(store.getMediaById(mid), undefined); });
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);