// 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 '))));
t('Sanitizer entfernt onerror=', () => assert.ok(!/onerror/i.test(sanitizeHtml('
'))));
t('Sanitizer neutralisiert javascript:', () => assert.ok(!/javascript:/i.test(sanitizeHtml('x'))));
t('Sanitizer lässt normales Markup', () => assert.ok(//.test(sanitizeHtml('fett'))));
// --- 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 1–5 → 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 (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);