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.
This commit is contained in:
@@ -71,5 +71,53 @@ t('Sanitizer lässt normales Markup', () => assert.ok(/<strong>/.test(sanitizeHt
|
||||
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 (0–100)', () => 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);
|
||||
|
||||
Reference in New Issue
Block a user