Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten

Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.

Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.

Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:12:23 +00:00
commit e87140e5ba
245 files changed
+38799 -1050

No files matched your search

+7 -5
View File
@@ -356,7 +356,8 @@ test('Kunde ohne actor-Namen oder Mitarbeiter ohne Textform: 400', async () => {
test('Historie: Kunde, Aus, An durch Mitarbeiter in zeitlicher Folge; Portal-Dokument gleich', async () => {
aw.seed('websiteProjects', { $id: 'p14', customerId: 'c1', projectName: 'Historie', subdomain: 'historie', isPublic: true })
const { doc: d1 } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust() })
// feste Zeiten vor NOW: mit der echten Uhr kippt die Reihenfolge, sobald die Uhr NOW überholt
const { doc: d1 } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust({ now: new Date(NOW.getTime() - 60_000) }) })
let h = await consents('p14')
assert.equal(h.length, 1)
assert.equal(h[0].event, 'on')
@@ -375,8 +376,8 @@ test('Historie: Kunde, Aus, An durch Mitarbeiter in zeitlicher Folge; Portal-Dok
assert.equal(d1.avvConsentDate, '')
assert.equal(d1.avvConsentChannel, '')
// Farbwechsel und erneutes Aktivieren: keine neue Zeile
await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', accentColor: '#222222', ...cust() })
await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust() })
await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', accentColor: '#222222', ...cust({ now: new Date(NOW.getTime() - 50_000) }) })
await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust({ now: new Date(NOW.getTime() - 40_000) }) })
assert.equal((await consents('p14')).length, 1)
// Marker zurücksetzen beim Wiedereinschalten
await updateDocument(config.collections.portalFeatures, d1.$id, { removalTicketId: 'r1', publishTicketId: 'u1' })
@@ -418,14 +419,15 @@ test('recordConsent scheitert: putSite wird zurückgenommen, Dokument bleibt aus
test('Ausschalten schreibt off-Historie; Fehler dort verhindert das Ausschalten nicht', async () => {
aw.seed('websiteProjects', { $id: 'p16', customerId: 'c1', projectName: 'Aus', subdomain: 'aus', isPublic: true })
await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust() })
// feste Zeiten vor NOW (siehe Historie-Test)
await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust({ now: new Date(NOW.getTime() - 120_000) }) })
const prev = an.analyseDeps.recordConsent
an.analyseDeps.recordConsent = async () => { throw new Error('Historie weg') }
const r = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p16', now: NOW, actor })
an.analyseDeps.recordConsent = prev
assert.equal(r.doc.state, 'off')
assert.deepEqual((await consents('p16')).map((x) => x.event), ['on'])
await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust() })
await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust({ now: new Date(NOW.getTime() - 60_000) }) })
await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p16', now: NOW, actor })
assert.deepEqual((await consents('p16')).map((x) => x.event), ['on', 'on', 'off'])
})
+404
View File
@@ -0,0 +1,404 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { headerHtml, tabsHtml, overviewHtml, summaryHtml, kpisHtml, sparkHtml, chartHtml, chartTipText, contactsHtml, rankCardHtml, basisHtml, heatTeaserHtml, skeletonHtml, errorHtml, settingsHtml } from '../public/analyse-cards.js'
// Testdaten: Form wie analytics/src/stats.js (gleiche Helfer wie test/analyseInsights.test.js)
const isoDay = (from, n) => new Date(Date.UTC(+from.slice(0, 4), +from.slice(5, 7) - 1, +from.slice(8, 10) + n)).toISOString().slice(0, 10)
const days = (from, n, pv) => Array.from({ length: n }, (_, i) => ({ day: isoDay(from, i), pv: typeof pv === 'function' ? pv(i, isoDay(from, i)) : pv }))
const rich = (rows) => rows.map((d) => ({ ...d, visitors: 80, contacts: 6, avgSec: 100 }))
const count = (html, re) => (html.match(re) || []).length
const t = { pv: 6930, visitors: 2410, contacts: 188, consentRate: 0.61, engaged: 1800, avgSec: 108, avgScroll: 64 }
const prev = {
from: '2026-08-06', to: '2026-09-04', complete: true,
totals: { pv: 6080, visitors: 2200, contacts: 143, consentRate: 0.6, engaged: 1500, avgSec: 114, avgScroll: 60 },
series: rich(days('2026-08-06', 30, 203)),
contacts: [{ key: 'tel', count: 80 }, { key: 'mail', count: 40 }, { key: 'wa', count: 23 }],
}
const stats = (over = {}) => ({
range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '2026-07-01',
today: { day: '2026-10-05', pv: 42, visitors: 20, contacts: 1 },
totals: t,
series: rich(days('2026-09-05', 30, (i) => (i === 14 ? 371 : 231))),
pages: [{ key: '/', count: 3200 }, { key: '/preise', count: 1310 }, { key: '/kontakt', count: 900 }],
refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }],
countries: [{ key: 'DE', count: 6000 }, { key: 'AT', count: 600 }, { key: '', count: 330 }],
devices: [{ key: 'm', count: 4590 }, { key: 'd', count: 2200 }, { key: 't', count: 140 }],
contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 52 }, { key: 'wa', count: 30 }, { key: 'out', count: 10 }],
banner: { shown: 3950, accept: 2410, reject: 1540 },
previous: prev,
...over,
})
const full = stats()
const zeroTotals = { pv: 0, visitors: 0, contacts: 0, consentRate: 0, engaged: 0, avgSec: null, avgScroll: null }
const zero = {
range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '',
totals: zeroTotals,
series: days('2026-09-05', 30, 0).map((d) => ({ ...d, visitors: 0, contacts: 0, avgSec: null })),
pages: [], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 0, accept: 0, reject: 0 },
previous: { from: '2026-08-06', to: '2026-09-04', complete: false, totals: zeroTotals, series: [], contacts: [] },
}
const OLD = { totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: [{ day: '2026-09-17', pv: 20 }, { day: '2026-09-18', pv: 30 }], pages: [], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 1, accept: 1, reject: 0 } }
const project = (over = {}) => ({ projectId: 'p1', projectName: 'Boulderhalle Überhang', analyse: { state: 'active', devMode: false, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal: { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' }, activatedAt: '2026-10-01T22:30:00.000Z' }, ...over })
test('overviewHtml: volle Daten – Kacheln mit Chips, Vorzeitraum-Linie, Kontakte, Quote', () => {
const h = overviewHtml(full)
assert.match(h, /▲ 14 %/); assert.match(h, /vs\. 6\.080/); assert.match(h, /class="an-prev"/)
assert.match(h, /Ø Zeit pro Seite/); assert.match(h, /1:48/); assert.match(h, /Ø 64 % gescrollt/)
assert.match(h, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen \(davor 6,5\)\./)
assert.match(h, /61 % haben dem Banner zugestimmt \(2\.410 von 3\.950 Entscheidungen\)/)
assert.match(h, /Das Wichtigste · 5\. Sep\. – 4\. Okt\./)
assert.match(h, /<b>14 % mehr<\/b>/) // Satz aus summarize
assert.match(h, /Länderdaten: IP-Geolocation by <a href="https:\/\/db-ip\.com" target="_blank" rel="noopener">DB-IP<\/a>/)
assert.match(h, /data-rank="seen"/); assert.match(h, /data-rank="who"/); assert.match(h, /data-tab="heatmap"/)
assert.doesNotMatch(h, /undefined|NaN|Infinity|null/)
})
test('overviewHtml: alter Collector ohne previous/firstDay – keine Chips, kein Fehler', () => {
const h = overviewHtml(OLD)
assert.doesNotMatch(h, /▲|▼|undefined|NaN|null/); assert.doesNotMatch(h, /class="an-prev"/)
assert.doesNotMatch(h, /an-delta/); assert.doesNotMatch(h, /Vergleich ab/)
assert.doesNotMatch(h, /Ø \d+ % gescrollt/) // kein avgScroll
assert.match(h, /<b class="an-v">612<\/b>/)
assert.equal(count(h, /<svg class="an-spark"/g), 1) // nur Seitenaufrufe haben eine Reihe (series hat nur pv)
})
test('overviewHtml: Website ohne Aufrufe – Leerzustand, keine NaN/Infinity', () => {
const h = overviewHtml(zero)
assert.match(h, /Sobald ein paar mehr Besuche da sind/); assert.match(h, /Noch hat niemand über das Banner entschieden\./)
assert.match(h, /Noch keine Kontakt-Aktionen\. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt\./)
assert.doesNotMatch(h, /NaN|Infinity|undefined|null/)
assert.doesNotMatch(h, /an-delta|Vergleich ab/) // firstDay '' => kein Datum
assert.match(overviewHtml({}), /Sobald ein paar mehr Besuche da sind/) // sogar eine leere Antwort bricht nichts
assert.doesNotMatch(overviewHtml({}), /NaN|Infinity|undefined|null/)
})
test('summaryHtml: Satz und Hinweise mit Symbol; Leerzustand unter 30 Aufrufen', () => {
const h = summaryHtml(full)
assert.match(h, /Deine Website wurde in den letzten 30 Tagen <b>6\.930-mal<\/b> aufgerufen/)
assert.equal(count(h, /<li>/g), 4); assert.match(h, /<svg class="an-ico"/)
assert.equal(count(h, /aria-hidden="true"><\/svg>/g), 0) // alle Symbole der Hinweise sind belegt
assert.match(h, /178-mal hat dich jemand direkt kontaktiert, meist per Telefon \(96\)\./) // ohne die 10 Links nach außen
const few = summaryHtml(stats({ totals: { ...t, pv: 12 } }))
assert.match(few, /Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen\./)
assert.doesNotMatch(few, /<ul/)
assert.match(summaryHtml({ totals: { pv: 3 } }), /<h3 class="an-eyebrow">Das Wichtigste<\/h3>/) // ohne from/to kein Zeitraum
})
test('kpisHtml: Chips, Vorwerte, Mini-Verläufe, Zeit mit Scrolltiefe', () => {
const h = kpisHtml(full)
assert.equal(count(h, /class="an-card an-kpi"/g), 4)
assert.equal(count(h, /an-delta up/g), 3); assert.equal(count(h, /an-delta down/g), 1)
assert.match(h, /▲ 10 %/); assert.match(h, /▲ 31 %/); assert.match(h, /▼ 6 s/)
assert.match(h, /vs\. 2\.200/); assert.match(h, /vs\. 143/); assert.match(h, /vs\. 1:54/)
assert.match(h, /aria-label="plus 14 Prozent gegenüber dem Zeitraum davor"/)
assert.match(h, /Besucher<span class="an-star">\*<\/span>/)
assert.equal(count(h, /<svg class="an-spark"/g), 4)
// Verlauf der Zeit: Tage ohne Messung (null) fehlen im Verlauf, sie werden nicht als 0 gezeichnet
const noTime = kpisHtml(stats({ series: full.series.map((d, i) => ({ ...d, avgSec: i === 0 ? null : 100 })) }))
assert.equal(count(noTime, /<svg class="an-spark"/g), 4)
const timeLine = (html) => /data-kpi="time".*?class="an-spark-line" d="([^"]*)"/s.exec(html)[1]
assert.match(timeLine(noTime), /^M6\.9 /) // der erste Tag (x = 0) fehlt, die Linie beginnt am zweiten
assert.equal(count(timeLine(noTime), /M/g), 1)
assert.doesNotMatch(timeLine(noTime), /M0 |L0 /)
assert.match(timeLine(kpisHtml(full)), /^M0 /) // vollständige Reihe: Linie ab dem ersten Tag
// Zählwerte behalten 0: ein Tag ohne Aufrufe ist ein echter Tiefpunkt
const zeroPv = kpisHtml(stats({ series: full.series.map((d, i) => ({ ...d, pv: i === 0 ? 0 : 231 })) }))
assert.match(/data-kpi="pv".*?class="an-spark-line" d="([^"]*)"/s.exec(zeroPv)[1], /^M0 36 /)
// Reihe ohne avgSec-Feld: kein Verlauf bei der Zeit
assert.equal(count(kpisHtml(stats({ series: full.series.map(({ avgSec, ...d }) => d) })), /<svg class="an-spark"/g), 3)
// keine Zeitmessung: Strich statt "0:00"
const none = kpisHtml(stats({ totals: { ...t, avgSec: null, avgScroll: null }, previous: { ...prev, totals: { ...prev.totals, avgSec: null } } }))
assert.match(none, /<b class="an-v">–<\/b>/); assert.doesNotMatch(none, /gescrollt|vs\. –/)
})
test('kpisHtml: neue Website zeigt "Vergleich ab 19.11."', () => {
const h = kpisHtml(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } }))
assert.equal(count(h, /Vergleich ab 19\.11\./g), 4)
assert.doesNotMatch(h, /an-delta|vs\./)
const k7 = kpisHtml(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } }))
assert.match(k7, /Vergleich ab 12\.10\./)
// vollständiger Vorzeitraum, aber unter 20 Aufrufen: weder Chip noch Datum
const tiny = kpisHtml(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 5 } } }))
assert.doesNotMatch(tiny, /an-delta|vs\.|Vergleich ab/)
})
test('kpisHtml: Richtungen – weniger und gleich', () => {
const less = kpisHtml(stats({ totals: { ...t, pv: 5000 } }))
assert.match(less, /<span class="an-delta down" role="img" aria-label="minus 18 Prozent gegenüber dem Zeitraum davor">▼ 18 %<\/span>/)
const same = kpisHtml(stats({ totals: { ...t, contacts: 143 } }))
assert.match(same, /an-delta flat/); assert.match(same, /± 0/)
})
test('sparkHtml: Fläche und Linie, nichts bei weniger als 2 Werten, kein NaN', () => {
const h = sparkHtml([1, 5, 3, 8])
assert.match(h, /<svg class="an-spark" viewBox="0 0 200 38" preserveAspectRatio="none"/)
assert.match(h, /class="an-spark-area"/); assert.match(h, /class="an-spark-line"/)
assert.equal(sparkHtml([4]), ''); assert.equal(sparkHtml([]), ''); assert.equal(sparkHtml(undefined), '')
assert.doesNotMatch(sparkHtml([0, 0, 0]), /NaN|Infinity/)
assert.doesNotMatch(sparkHtml([3, null, undefined, 'x']), /NaN|undefined|null/)
})
test('sparkHtml: Lücken (null) unterbrechen die Linie statt als 0 zu gelten', () => {
const line = (h) => /class="an-spark-line" d="([^"]*)"/.exec(h)[1]
const area = (h) => /class="an-spark-area" d="([^"]*)"/.exec(h)[1]
// Wert 0 liegt auf der Grundlinie (y = 36); eine Lücke darf dort keinen Punkt erzeugen
const gap = sparkHtml([4, 6, null, 6, 8])
assert.equal(count(line(gap), /M/g), 2) // zwei Abschnitte: Tage 0-1 und 3-4
assert.doesNotMatch(line(gap), / 36( |$)/)
assert.equal(count(area(gap), /Z/g), 2) // Fläche je Abschnitt
assert.doesNotMatch(line(gap), /(^|[ML])100 /) // der Tag mit Lücke (x = 100) hat keinen Punkt
assert.match(line(sparkHtml([null, 5, 7, 9])), /^M66\.7 /) // Lücke am Anfang: Linie beginnt später, nicht bei 0
assert.equal(sparkHtml([null, null, null]), ''); assert.equal(sparkHtml([null, 4]), '') // unter 2 Messwerten nichts
assert.doesNotMatch(sparkHtml([4, null, 6]), /NaN|undefined|null/)
// alle Werte vorhanden: ein Abschnitt wie bisher
assert.equal(count(line(sparkHtml([1, 5, 3, 8])), /M/g), 1)
})
test('chartHtml: Linie, Fläche, Vorzeitraum nur mit Vergleich, Legende, Punkte mit Daten', () => {
const h = chartHtml(full)
assert.match(h, /<title id="an-chart-title">Seitenaufrufe pro Tag vom 05\.09\. bis 04\.10\., insgesamt 7\.\d{3}<\/title>/)
assert.match(h, /class="an-area"/); assert.match(h, /class="an-line"/); assert.match(h, /class="an-prev"/)
assert.match(h, /30 Tage/); assert.match(h, /Zeitraum davor/)
assert.equal(count(h, /class="an-dot"/g), 30)
assert.match(h, /data-i="14" /); assert.match(h, /data-day="2026-09-19"/); assert.match(h, /data-pv="371"/); assert.match(h, /data-prev="203"/)
assert.match(h, /class="an-tip"/)
// ohne Vergleich: keine gestrichelte Linie, keine zweite Legende, kein data-prev
const nc = chartHtml(stats({ previous: { ...prev, complete: false } }))
assert.doesNotMatch(nc, /class="an-prev"|Zeitraum davor|data-prev/)
assert.match(nc, /30 Tage/)
// ganz ohne Daten
assert.match(chartHtml({ series: [] }), /Noch keine Daten\./)
assert.doesNotMatch(chartHtml(zero), /NaN|Infinity|undefined|null/)
})
test('chartHtml: Achsenbeschriftung hat Platz – y-Rand wächst mit der Stellenzahl, x-Beschriftungen überlappen nicht', () => {
const padOf = (html) => Number(/class="an-grid" x1="([\d.]+)"/.exec(html)[1])
const series = (pv, n = 30) => days('2026-09-05', n, pv).map((d) => ({ ...d, visitors: 1, contacts: 0, avgSec: 1 }))
const none = { previous: { ...prev, complete: false } }
// Skala 500 (3 Zeichen), 5.000 (4), 20.000 (5), 50 (2): Platz für die Ziffern der y-Beschriftung
assert.equal(padOf(chartHtml(stats({ series: series(300), ...none }))), 49)
assert.equal(padOf(chartHtml(stats({ series: series(2400), ...none }))), 64)
assert.equal(padOf(chartHtml(stats({ series: series(12000), ...none }))), 78)
assert.equal(padOf(chartHtml(stats({ series: series(30), ...none }))), 40)
// y-Beschriftung steht rechtsbündig 6 Einheiten vor dem Raster und ist an der Gitterlinie zentriert
const h = chartHtml(stats({ series: series(2400), ...none }))
assert.match(h, /<text class="an-axis" x="58" y="[\d.]+" dy="0\.35em" text-anchor="end">5000<\/text>/)
// x-Beschriftungen: nie näher als 100 Einheiten (Platz für "29.09." in der größten Schrift), letzter Tag bleibt
for (const n of [7, 30, 90]) {
const xs = [...chartHtml(stats({ series: series(300, n), ...none })).matchAll(/class="an-axis" x="([\d.]+)" y="[\d.]+" text-anchor="middle"/g)].map((m) => Number(m[1]))
assert.ok(xs.length >= 3, `range ${n}`)
assert.ok(xs.every((x, i) => !i || x - xs[i - 1] >= 100), `range ${n}: ${xs}`)
assert.equal(xs[xs.length - 1], 616) // 640 - rechter Rand 24: der letzte Tag
}
})
test('chartTipText: Blase mit und ohne Vorwert', () => {
assert.equal(chartTipText('2026-09-19', 371, 210), 'Sa., 19.09.: 371 Aufrufe (davor 210)')
assert.equal(chartTipText('2026-09-19', '371', '210'), 'Sa., 19.09.: 371 Aufrufe (davor 210)') // Werte aus data-Attributen sind Strings
assert.equal(chartTipText('2026-09-19', 371), 'Sa., 19.09.: 371 Aufrufe')
assert.equal(chartTipText('2026-09-19', 371, null), 'Sa., 19.09.: 371 Aufrufe')
assert.equal(chartTipText('2026-09-19', 1, 0), 'Sa., 19.09.: 1 Aufruf (davor 0)')
assert.equal(chartTipText('2026-09-19', 6930, 6080), 'Sa., 19.09.: 6.930 Aufrufe (davor 6.080)')
})
test('contactsHtml: Arten mit 0 in beiden Zeiträumen entfallen, Leertext ohne Kontakte', () => {
const h = contactsHtml(full)
assert.match(h, /<b class="an-sum-n">188<\/b>/)
assert.equal(count(h, /aria-hidden="true"><\/svg>/g), 0) // jede Zeile hat ihr Symbol
assert.equal(count(h, /<svg class="an-ico"/g), 4)
for (const label of ['Anrufe', 'E-Mails', 'WhatsApp', 'Links nach außen']) assert.match(h, new RegExp(label))
assert.match(h, /data-kind="tel"[^]*?<b>96<\/b>[^]*?▲ 16/) // 96 vs 80: beide unter 100 => absolut statt Prozent
assert.match(h, /data-kind="out"[^]*?<b>10<\/b>[^]*?▲ 10/) // 10 vs 0
// Art ohne Kontakte, in beiden Zeiträumen 0: Zeile entfällt
const noOut = contactsHtml(stats({ contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 52 }, { key: 'out', count: 0 }], totals: { ...t, contacts: 148 }, previous: { ...prev, contacts: [{ key: 'tel', count: 80 }, { key: 'mail', count: 40 }] } }))
assert.doesNotMatch(noOut, /data-kind="out"|data-kind="wa"|WhatsApp|Links nach außen/)
assert.match(noOut, /data-kind="tel"/); assert.match(noOut, /data-kind="mail"/)
// Vorzeitraum hatte WhatsApp, jetzt 0: Zeile bleibt mit ▼
const lost = contactsHtml(stats({ contacts: [{ key: 'tel', count: 96 }], totals: { ...t, contacts: 96 } }))
assert.match(lost, /data-kind="wa"[^]*?<b>0<\/b>[^]*?▼ 23/)
// ohne Vergleich: keine Chips, Zeilen nur mit Kontakten
const nc = contactsHtml(stats({ previous: { ...prev, complete: false } }))
assert.doesNotMatch(nc, /an-delta|davor/); assert.match(nc, /data-kind="tel"/)
// Leertext
const none = contactsHtml(zero)
assert.match(none, /Noch keine Kontakt-Aktionen\. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt\./)
assert.doesNotMatch(none, /data-kind|an-quote/)
// 0 Kontakte, aber der Vorzeitraum hatte welche: weiter Leertext (Kopfzahl 0)
assert.match(contactsHtml(stats({ totals: { ...t, contacts: 0 }, contacts: [] })), /Noch keine Kontakt-Aktionen\./)
})
test('contactsHtml: Quote pro 100 Besucher, mit und ohne Vorwert, erst ab 30 Besuchern', () => {
assert.match(contactsHtml(full), /<p class="an-quote">Auf 100 Besucher kommen 7,8 Kontakt-Aktionen \(davor 6,5\)\.<\/p>/)
const nc = contactsHtml(stats({ previous: { ...prev, complete: false } }))
assert.match(nc, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen\./)
const fewPrev = contactsHtml(stats({ previous: { ...prev, totals: { ...prev.totals, visitors: 10 } } }))
assert.match(fewPrev, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen\./) // Vorquote unsicher => ohne "davor"
assert.doesNotMatch(contactsHtml(stats({ totals: { ...t, visitors: 29, contacts: 3 }, contacts: [{ key: 'tel', count: 3 }] })), /an-quote/)
assert.match(contactsHtml(stats({ totals: { ...t, visitors: 400, contacts: 28 } })), /kommen 7,0 Kontakt-Aktionen/) // immer eine Nachkommastelle
})
test('rankCardHtml: Startseite statt /, Anteil in %, 6 Zeilen, expanded bis 20, Herkunft mit refName, Pfad maskiert', () => {
const h = rankCardHtml('seen', stats({ pages: [{ key: '/<img src=x>', count: 9 }] }))
assert.match(h, /&lt;img src=x&gt;/); assert.doesNotMatch(h, /<img src=x>/)
const pages = rankCardHtml('seen', full)
assert.match(pages, /Startseite/); assert.doesNotMatch(pages, />\/</)
assert.match(pages, /<b>3\.200<\/b><em>46 %<\/em>/) // 3200 von 6930 Aufrufen (die Liste zeigt nur 5410 davon)
assert.match(pages, /data-rank="seen" data-rank-active="pages"/)
assert.match(pages, /<h3>Was angesehen wird<\/h3>/)
assert.match(pages, /data-rank-tab="pages" aria-pressed="true"/); assert.match(pages, /data-rank-tab="refs" aria-pressed="false"/)
// Herkunft
const refs = rankCardHtml('seen', full, { tab: 'refs' })
assert.match(refs, /Google/); assert.match(refs, /Direkt oder unbekannt/); assert.match(refs, /Instagram/); assert.match(refs, /Facebook/)
assert.doesNotMatch(refs, /google\.com|l\.facebook/); assert.match(refs, /data-rank-active="refs"/)
// 6 Zeilen, "Alle anzeigen" nur bei mehr als 6; expanded bis 20
const many = stats({ pages: Array.from({ length: 25 }, (_, i) => ({ key: `/seite-${i}`, count: 100 - i })) })
const six = rankCardHtml('seen', many)
assert.equal(count(six, /class="an-lr"/g), 6); assert.match(six, /data-rank-more[^>]*>Alle anzeigen</)
const open = rankCardHtml('seen', many, { expanded: true })
assert.equal(count(open, /class="an-lr"/g), 20); assert.doesNotMatch(open, /Alle anzeigen/)
assert.doesNotMatch(rankCardHtml('seen', stats({ pages: Array.from({ length: 6 }, (_, i) => ({ key: `/s${i}`, count: 9 })) })), /Alle anzeigen/)
assert.equal(count(rankCardHtml('seen', stats({ pages: Array.from({ length: 6 }, (_, i) => ({ key: `/s${i}`, count: 9 })) })), /class="an-lr"/g), 6)
assert.match(rankCardHtml('seen', many), /an-fill" style="width:100%"/) // größter Wert = volle Breite
})
test('rankCardHtml: Geräte, Länder mit Flagge, ungültiger Reiter, leere Liste, Anteil unter 1 %', () => {
const dev = rankCardHtml('who', full)
assert.match(dev, /<h3>Wer zu Besuch ist<\/h3>/); assert.match(dev, /Handy/); assert.match(dev, /Computer/); assert.match(dev, /Tablet/)
assert.match(dev, /<b>4\.590<\/b><em>66 %<\/em>/)
assert.match(rankCardHtml('who', stats({ devices: [{ key: 'x', count: 2 }, { key: 'constructor', count: 1 }] })), /Unbekannt/)
const land = rankCardHtml('who', full, { tab: 'countries' })
assert.match(land, /🇩🇪/); assert.match(land, /Deutschland/); assert.match(land, /Österreich/); assert.match(land, /Unbekannt/)
assert.match(rankCardHtml('who', full, { tab: 'pages' }), /data-rank-active="devices"/) // Reiter einer anderen Karte => erster Reiter
assert.match(rankCardHtml('seen', stats({ pages: [] })), /Noch keine Daten\./)
assert.equal(rankCardHtml('nope', full), '')
const tiny = rankCardHtml('seen', stats({ pages: [{ key: '/a', count: 10000 }, { key: '/b', count: 1 }] }))
assert.match(tiny, /<em>&lt; 1 %<\/em>/)
assert.doesNotMatch(rankCardHtml('seen', stats({ pages: [{ key: '/a', count: 0 }] })), /NaN|Infinity/)
// Nenner: Seiten, Länder, Geräte relativ zu max(Summe der Liste, Seitenaufrufe); Herkunft nur zur Summe der Liste
const part = stats({ totals: { ...t, pv: 1000 }, pages: [{ key: '/a', count: 300 }, { key: '/b', count: 100 }], countries: [{ key: 'DE', count: 250 }], devices: [{ key: 'm', count: 500 }], refs: [{ key: 'google.com', count: 30 }, { key: '', count: 10 }] })
assert.match(rankCardHtml('seen', part), /<b>300<\/b><em>30 %<\/em>/) // 300 von 1000, nicht von 400 (75 %)
assert.match(rankCardHtml('who', part, { tab: 'countries' }), /<b>250<\/b><em>25 %<\/em>/)
assert.match(rankCardHtml('who', part), /<b>500<\/b><em>50 %<\/em>/)
assert.match(rankCardHtml('seen', part, { tab: 'refs' }), /<b>30<\/b><em>75 %<\/em>/) // Herkunft: 30 von 40
// Liste größer als die Gesamtzahl (Zählungen laufen auseinander): die Summe, nie mehr als 100 %
const over = stats({ totals: { ...t, pv: 100 }, pages: [{ key: '/a', count: 300 }, { key: '/b', count: 100 }] })
assert.match(rankCardHtml('seen', over), /<b>300<\/b><em>75 %<\/em>/)
// ohne totals (alter Collector): Summe der Liste
assert.match(rankCardHtml('seen', { pages: [{ key: '/a', count: 3 }, { key: '/b', count: 1 }] }), /<em>75 %<\/em>/)
// Länder-Schlüssel maskiert
const bad = rankCardHtml('who', stats({ countries: [{ key: '<b>', count: 3 }] }), { tab: 'countries' })
assert.match(bad, /Unbekannt/); assert.doesNotMatch(bad, /<b><|<b>&/); assert.doesNotMatch(bad, /an-flag/)
})
test('basisHtml: Ring, Sätze mit und ohne Entscheidungen', () => {
const h = basisHtml(full)
assert.match(h, /<text class="an-ring-n"[^>]*>61 %<\/text>/); assert.match(h, /class="an-ring-fg"/)
assert.match(h, /Seitenaufrufe zählen wir vollständig; Besucher, Kontakt-Aktionen und Zeit pro Seite \(\*\) nur bei Zustimmung\./)
const none = basisHtml(zero)
assert.match(none, /Noch hat niemand über das Banner entschieden\./); assert.doesNotMatch(none, /an-ring-fg|haben dem Banner zugestimmt|NaN/)
assert.match(none, />–<\/text>/)
assert.match(basisHtml(stats({ banner: { shown: 1, accept: 1, reject: 0 }, totals: { ...t, consentRate: 1 } })), /\(1 von 1 Entscheidung\)/)
assert.doesNotMatch(basisHtml({}), /NaN|undefined|null/)
})
test('headerHtml: Auswahl nur bei mehreren Websites, Zeitraum nur bei showRange, Dev-Hinweis, Name maskiert', () => {
const a = project()
const b = { projectId: 'p2', projectName: 'Café "Zum <Bären>"' }
const one = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: false, stamp: '19:42', today: { day: '2026-10-05', pv: 1234, visitors: 80, contacts: 3 } })
assert.doesNotMatch(one, /an-project|<select/); assert.match(one, /<b class="an-site-name">Boulderhalle Überhang<\/b>/)
assert.match(one, /<small class="an-status"><span class="an-st">Analyse aktiv<\/span> · <span class="an-st">Heute bisher: 1\.234 Aufrufe<\/span> · <span class="an-st">Stand: 19:42 Uhr<\/span><\/small>/)
assert.match(one, /<button type="button" class="on" data-range="30" aria-pressed="true">30 Tage<\/button>/)
assert.equal(count(one, /data-range="/g), 3); assert.match(one, /data-range="7"[^>]*aria-pressed="false"/)
const two = headerHtml({ project: b, usable: [a, b], range: 7, showRange: false, devMode: false, stamp: '' })
assert.match(two, /<select id="an-project"/); assert.match(two, /<option value="p2" selected>Café &quot;Zum &lt;Bären&gt;&quot;<\/option>/)
assert.match(two, /<option value="p1">Boulderhalle Überhang<\/option>/)
assert.doesNotMatch(two, /data-range/); assert.doesNotMatch(two, /Zum <Bären>/); assert.doesNotMatch(two, /Stand:/)
assert.match(two, /Analyse aktiv/); assert.doesNotMatch(two, /Heute bisher/) // ohne today (alter Collector) entfällt der Teil
assert.match(headerHtml({ project: a, usable: [a], range: 365, showRange: true }), /class="on" data-range="30"/) // unbekannter Zeitraum => 30
// Dev-Modus
const dev = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: true, stamp: '19:42' })
assert.match(dev, /Im Dev-Modus wird nicht gemessen\./); assert.doesNotMatch(dev, /Analyse aktiv|Stand:|Heute bisher/); assert.match(dev, /data-state="warn"/)
assert.match(one, /data-state="on"/)
// Name maskiert
const evil = headerHtml({ project: { projectId: 'x', projectName: '<img src=x onerror=alert(1)>' }, usable: [], range: 30, showRange: false })
assert.doesNotMatch(evil, /<img/); assert.match(evil, /&lt;img src=x onerror=alert\(1\)&gt;/)
const odd = headerHtml({ project: { projectId: 'p"1', projectName: 'A' }, usable: [{ projectId: 'p"1', projectName: 'A' }, { projectId: 'p2', projectName: 'B' }], stamp: '<b>x</b>', today: { pv: '<i>5</i>' } })
assert.match(odd, /value="p&quot;1" selected/); assert.match(odd, /Stand: &lt;b&gt;x&lt;\/b&gt; Uhr/); assert.doesNotMatch(odd, /<b>x|<i>/)
// Heute bisher: Einzahl, 0 und nur mit Zahl; ohne Uhrzeit nur der Zähler
const today = (pv, stamp = '') => headerHtml({ project: a, usable: [a], range: 30, showRange: false, stamp, today: { day: '2026-10-05', pv, visitors: 0, contacts: 0 } })
const plain = (h) => /<small class="an-status">(.*?)<\/small>/.exec(h)[1].replace(/<[^>]+>/g, '') // Text ohne die Umbruch-Spans
assert.equal(plain(today(1, '08:05')), 'Analyse aktiv · Heute bisher: 1 Aufruf · Stand: 08:05 Uhr')
assert.match(today(0, '08:05'), /Heute bisher: 0 Aufrufe/)
assert.equal(plain(today(42)), 'Analyse aktiv · Heute bisher: 42 Aufrufe')
assert.doesNotMatch(headerHtml({ project: a, usable: [a], today: {} }), /Heute bisher|NaN/) // today ohne pv: nichts behaupten
// Zustände ohne Messung zeigen kein "Heute bisher"
assert.doesNotMatch(headerHtml({ project: project({ analyse: { state: 'disabled' } }), usable: [], today: { pv: 5 } }), /Heute bisher/)
// Zustände ohne Messung sagen nicht "aktiv"
const off = headerHtml({ project: project({ analyse: { state: 'off', eligible: true } }), usable: [], range: 30, showRange: false })
assert.doesNotMatch(off, /Analyse aktiv/); assert.match(off, /data-state="off"/)
assert.match(headerHtml({ project: project({ analyse: { state: 'disabled' } }), usable: [] }), /Analyse ist abgeschaltet/)
assert.match(headerHtml({ project: project({ analyse: { state: 'off', eligible: false } }), usable: [] }), /Analyse nicht verfügbar/)
assert.doesNotMatch(headerHtml({}), /undefined|null|NaN/)
})
test('tabsHtml: tablist, drei Reiter, aria-selected', () => {
const h = tabsHtml('heatmap')
assert.match(h, /role="tablist"/); assert.equal(count(h, /role="tab"/g), 3)
for (const k of ['uebersicht', 'heatmap', 'einstellungen']) assert.match(h, new RegExp(`data-tab="${k}"`))
assert.match(h, /data-tab="heatmap" aria-selected="true"/); assert.match(h, /data-tab="uebersicht" aria-selected="false"/)
assert.equal(count(h, /aria-selected="true"/g), 1)
assert.match(tabsHtml('quatsch'), /data-tab="uebersicht" aria-selected="true"/)
assert.match(h, />Übersicht<\/button>/); assert.match(h, />Heatmap<\/button>/); assert.match(h, />Einstellungen<\/button>/)
})
test('heatTeaserHtml: Knopf wechselt in den Reiter Heatmap', () => {
const h = heatTeaserHtml()
assert.match(h, /<button type="button" class="an-btn" data-tab="heatmap">Öffnen<\/button>/); assert.match(h, /<h3>Heatmap<\/h3>/)
assert.match(h, /an-mini-heat/)
})
test('settingsHtml: aktiv seit TT.MM.JJJJ, Farbfeld, Fassung, Links AVV und Leistungsbeschreibung, #an-off', () => {
const h = settingsHtml(project())
assert.match(h, /aktiv seit 02\.10\.2026/) // 22:30 UTC = 00:30 Berlin am 02.10.
assert.match(h, /class="an-swatch" style="background:#1d4ed8"/); assert.match(h, /#1d4ed8/)
assert.match(h, /Fassung 2026-10-02/)
assert.match(h, /<a href="\/rechtliches\/avv-website-analyse-2026-10-02\.html" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse<\/a>/)
assert.match(h, /<a href="\/rechtliches\/leistungsbeschreibung-website-analyse-2026-10-02\.html" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse<\/a>/)
assert.match(h, /<section class="an-card" id="an-off"><\/section>/)
// Farbe ungültig => #111111; kein Datum => ohne "seit"
const bad = settingsHtml(project({ analyse: { state: 'active', accentColor: 'red;"><x>', avvVersion: '2026-10-02', legal: {}, activatedAt: null } }))
assert.match(bad, /background:#111111/); assert.doesNotMatch(bad, /<x>|seit|<a /)
assert.match(bad, /id="an-off"/)
// zu helle Farbe: das Banner nutzt Schwarz, das Farbfeld zeigt dasselbe (bannerColor)
const light = settingsHtml(project({ analyse: { state: 'active', accentColor: '#ffee00', avvVersion: '2026-10-02', legal: {}, activatedAt: null } }))
assert.match(light, /class="an-swatch" style="background:#111111"/); assert.doesNotMatch(light, /#ffee00/i)
assert.match(settingsHtml(project({ analyse: { state: 'active', accentColor: '#1D4ED8' } })), /background:#1d4ed8/) // groß geschrieben => klein
// gefährliche Link-Ziele und Namen
const evil = settingsHtml(project({ analyse: { state: 'active', accentColor: '#fff', avvVersion: '<b>', legal: { avv: 'javascript:alert(1)', lb: '/x"onmouseover="y' }, activatedAt: 'kaputt' } }))
assert.doesNotMatch(evil, /javascript:|<b>|onmouseover="y/); assert.doesNotMatch(evil, /NaN|undefined|null|Invalid/)
assert.doesNotMatch(settingsHtml({ projectId: 'p' }), /NaN|undefined|null|Invalid/)
})
test('errorHtml und skeletonHtml', () => {
assert.match(errorHtml('Auswertung gerade nicht erreichbar'), /id="an-retry"/)
assert.match(errorHtml('Auswertung gerade nicht erreichbar'), /Auswertung gerade nicht erreichbar/); assert.match(errorHtml('x'), /role="alert"/); assert.match(errorHtml('x'), />Neu laden</)
assert.match(errorHtml('<img src=x>'), /&lt;img src=x&gt;/); assert.doesNotMatch(errorHtml('<img src=x>'), /<img/)
const sk = skeletonHtml()
assert.equal(count(sk, /class="an-card an-kpi an-sk"/g), 4); assert.match(sk, /aria-busy="true"/)
assert.doesNotMatch(sk, /Lade…|undefined|NaN/)
})
test('Alle Bausteine: Werte aus Daten werden maskiert (Pfade, Hosts, Tage, Länder)', () => {
const evil = '"><script>alert(1)</script>'
const s = stats({
pages: [{ key: evil, count: 5 }],
refs: [{ key: evil, count: 5 }],
countries: [{ key: evil, count: 5 }],
devices: [{ key: evil, count: 5 }],
series: [{ day: evil, pv: 5 }, { day: '2026-09-18', pv: 7 }],
previous: { ...prev, series: [{ pv: 3 }, { pv: 4 }] },
})
const all = overviewHtml(s) + rankCardHtml('seen', s, { tab: 'refs' }) + rankCardHtml('who', s, { tab: 'countries' }) + rankCardHtml('who', s) + chartHtml(s)
assert.doesNotMatch(all, /<script>/)
assert.match(all, /&lt;script&gt;/)
})
+289
View File
@@ -0,0 +1,289 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { delta, fmtDuration, fmtNum, dayLabel, periodLabel, compareFrom, contactRate, refName, weekdayStats, summarize, chartModel } from '../public/analyse-view.js'
// Testdaten: eine vollstaendige Statistik-Antwort mit 30 Tagen (Form wie analytics/src/stats.js)
const isoDay = (from, n) => new Date(Date.UTC(+from.slice(0, 4), +from.slice(5, 7) - 1, +from.slice(8, 10) + n)).toISOString().slice(0, 10)
const days = (from, n, pv) => Array.from({ length: n }, (_, i) => ({ day: isoDay(from, i), pv: typeof pv === 'function' ? pv(i, isoDay(from, i)) : pv }))
const t = { pv: 6930, visitors: 2410, contacts: 188, consentRate: 0.61, engaged: 1800, avgSec: 108, avgScroll: 64 }
const prev = {
from: '2026-08-06', to: '2026-09-04', complete: true,
totals: { pv: 6080, visitors: 2200, contacts: 143, consentRate: 0.6, engaged: 1500, avgSec: 114, avgScroll: 60 },
series: days('2026-08-06', 30, 203),
contacts: [{ key: 'tel', count: 80 }, { key: 'mail', count: 40 }, { key: 'wa', count: 23 }],
}
const stats = (over = {}) => ({
range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '2026-07-01',
today: { day: '2026-10-05', pv: 42, visitors: 20, contacts: 1 },
totals: t,
series: days('2026-09-05', 30, 231),
pages: [{ key: '/', count: 3200 }, { key: '/preise', count: 1310 }, { key: '/kontakt', count: 900 }],
refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }],
countries: [{ key: 'DE', count: 6000 }],
devices: [{ key: 'm', count: 4590 }, { key: 'd', count: 2200 }, { key: 't', count: 140 }],
contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 52 }, { key: 'wa', count: 30 }, { key: 'out', count: 10 }],
banner: { shown: 3950, accept: 2410, reject: 1540 },
previous: prev,
...over,
})
test('delta: Prozent ab 100, sonst absolut, flat, Sekunden, null', () => {
assert.deepEqual(delta(6930, 6080), { dir: 'up', text: '▲ 14 %', aria: 'plus 14 Prozent gegenüber dem Zeitraum davor' })
assert.equal(delta(188, 143).text, '▲ 31 %') // beide ≥ 100 → Prozent
assert.equal(delta(45, 33).text, '▲ 12')
assert.equal(delta(30, 33).text, '▼ 3')
assert.deepEqual(delta(7, 7), { dir: 'flat', text: '± 0', aria: 'unverändert gegenüber dem Zeitraum davor' })
assert.equal(delta(1004, 1000).text, '± 0 %') // |0,4 %| < 1
assert.equal(delta(1004, 1000).dir, 'flat')
assert.equal(delta(108, 114, { kind: 'sec' }).text, '▼ 6 s')
assert.equal(delta(200, 135, { kind: 'sec' }).text, '▲ 1:05')
assert.equal(delta(null, 3), null)
assert.equal(delta(3, undefined), null)
assert.equal(delta('3', 4), null)
})
test('delta: Richtung und aria in Wörtern', () => {
assert.deepEqual(delta(5000, 6000), { dir: 'down', text: '▼ 17 %', aria: 'minus 17 Prozent gegenüber dem Zeitraum davor' })
assert.equal(delta(45, 33).aria, 'plus 12 gegenüber dem Zeitraum davor')
assert.equal(delta(30, 33).aria, 'minus 3 gegenüber dem Zeitraum davor')
assert.equal(delta(108, 114, { kind: 'sec' }).aria, 'minus 6 Sekunden gegenüber dem Zeitraum davor')
assert.equal(delta(200, 135, { kind: 'sec' }).aria, 'plus 1 Minute 5 Sekunden gegenüber dem Zeitraum davor')
assert.equal(delta(240, 120, { kind: 'sec' }).aria, 'plus 2 Minuten gegenüber dem Zeitraum davor')
})
test('fmtDuration, dayLabel, periodLabel, fmtNum', () => {
assert.equal(fmtDuration(108), '1:48'); assert.equal(fmtDuration(7), '0:07'); assert.equal(fmtDuration(null), '–')
assert.equal(fmtDuration(NaN), '–'); assert.equal(fmtDuration(-3), '–'); assert.equal(fmtDuration(undefined), '–')
assert.equal(dayLabel('2026-09-19'), 'Sa., 19.09.')
assert.equal(dayLabel('2026-09-20'), 'So., 20.09.')
assert.equal(periodLabel('2026-09-05', '2026-10-04'), '5. Sep. – 4. Okt.')
assert.equal(periodLabel('2026-03-01', '2026-06-30'), '1. März – 30. Juni')
assert.equal(periodLabel('', ''), '')
assert.equal(periodLabel('2026-09-05', ''), '')
assert.equal(fmtNum(6930), '6.930')
assert.equal(fmtNum(0), '0')
assert.equal(fmtNum(1234567), '1.234.567')
})
test('compareFrom: ok, Datum bei neuer Website, nichts ohne previous', () => {
assert.deepEqual(compareFrom(stats()), { ok: true, from: '' })
assert.deepEqual(compareFrom(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })), { ok: false, from: '19.11.' }) // range 30: +60 Tage (2 × range, der erste Tag mit vollem Vorzeitraum)
assert.deepEqual(compareFrom(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } })), { ok: false, from: '12.10.' }) // range 7: +14 Tage
assert.deepEqual(compareFrom(stats({ firstDay: '', previous: { ...prev, complete: false } })), { ok: false, from: '' })
assert.deepEqual(compareFrom({ totals: { pv: 50 }, series: [] }), { ok: false, from: '' })
assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 19 } } })).ok, false)
assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 20 } } })).ok, true)
})
test('contactRate und refName', () => {
assert.equal(contactRate(188, 2410), 7.8); assert.equal(contactRate(5, 29), null)
assert.equal(contactRate(0, 30), 0); assert.equal(contactRate(null, 100), null)
assert.equal(refName('www.google.de'), 'Google'); assert.equal(refName('l.facebook.com'), 'Facebook'); assert.equal(refName(''), 'Direkt oder unbekannt'); assert.equal(refName('blog.example'), 'blog.example')
assert.equal(refName('google.com'), 'Google'); assert.equal(refName('google.co.uk'), 'Google')
assert.equal(refName('www.bing.com'), 'Bing'); assert.equal(refName('l.instagram.com'), 'Instagram')
assert.equal(refName('m.facebook.com'), 'Facebook'); assert.equal(refName('facebook.com'), 'Facebook')
assert.equal(refName('duckduckgo.com'), 'DuckDuckGo'); assert.equal(refName('www.ecosia.org'), 'Ecosia')
assert.equal(refName('notgoogle.com'), 'notgoogle.com'); assert.equal(refName('google.evil.de'), 'google.evil.de')
assert.equal(refName('Google.DE'), 'Google')
})
test('weekdayStats: stärkster Wochentag, Spitzentag, < 7 Tage null', () => {
// 05.09.2026 ist ein Samstag: 5 Samstage mit 20, alle anderen 10 → Schnitt 11,67; Samstag 20 ≥ 1,2 × 11,67, aber < 1,8 × 11,67 (kein Spitzentag)
const sat = (i, day) => (new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 20 : 10)
const w = weekdayStats(days('2026-09-05', 30, sat), '')
assert.equal(w.best.weekday, 6); assert.equal(w.best.name, 'Samstag'); assert.equal(w.best.avg, 20)
assert.ok(Math.abs(w.avg - 350 / 30) < 1e-9)
assert.equal(w.peak, null)
// ein Tag mit dem 3-Fachen des Schnitts → Spitzentag
const spike = (i, day) => (day === '2026-09-19' ? 60 : 10)
const p = weekdayStats(days('2026-09-05', 30, spike), '')
assert.deepEqual(p.peak, { day: '2026-09-19', pv: 60 })
assert.equal(p.best.name, 'Samstag')
// gleichmäßig: weder stärkster Tag noch Spitzentag
assert.deepEqual(weekdayStats(days('2026-09-05', 30, 10), ''), { best: null, avg: 10, peak: null })
// weniger als 7 Tage → null; firstDay schneidet Tage davor ab
assert.equal(weekdayStats(days('2026-09-05', 6, 10), ''), null)
assert.notEqual(weekdayStats(days('2026-09-05', 30, 10), '2026-09-28'), null) // 28.09.–04.10. = genau 7 Tage
})
test('weekdayStats: firstDay zählt nur Tage ab firstDay', () => {
assert.equal(weekdayStats(days('2026-09-05', 30, 10), '2026-09-29'), null) // nur 6 Tage (29.09.–04.10.)
const w = weekdayStats(days('2026-09-05', 30, (i, day) => (day < '2026-09-20' ? 1000 : 10)), '2026-09-20')
assert.equal(w.avg, 10) // die Tage davor (1000) bleiben außen vor
assert.equal(weekdayStats(null, ''), null)
})
test('summarize: Wachstum mit Prozent', () => {
const s = summarize(stats())
assert.equal(s.lead, 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen – <b>14 % mehr</b> als in den 30 Tagen davor.')
assert.equal(s.items[0].icon, 'contact')
assert.equal(s.items[0].html, '178-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 35 mehr als zuvor.') // Telefon + E-Mail + WhatsApp: 96 + 52 + 30 gegen 80 + 40 + 23
assert.ok(s.items.length <= 4)
})
test('summarize: Rückgang, etwa gleich, kleine Zahlen absolut', () => {
const lead = (totalsPv, prevPv) => summarize(stats({ totals: { ...t, pv: totalsPv }, previous: { ...prev, totals: { ...prev.totals, pv: prevPv } } })).lead
assert.equal(lead(6930, 7615), 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen – <b>9 % weniger</b> als in den 30 Tagen davor.')
assert.equal(lead(6930, 6800), 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen – etwa so oft wie in den 30 Tagen davor.')
assert.equal(lead(80, 68), 'Deine Website wurde in den letzten 30 Tagen <b>80-mal</b> aufgerufen – <b>12 Aufrufe mehr</b> als in den 30 Tagen davor.')
assert.equal(lead(60, 75), 'Deine Website wurde in den letzten 30 Tagen <b>60-mal</b> aufgerufen – <b>15 Aufrufe weniger</b> als in den 30 Tagen davor.')
assert.equal(lead(84, 83), 'Deine Website wurde in den letzten 30 Tagen <b>84-mal</b> aufgerufen – etwa so oft wie in den 30 Tagen davor.') // unter 5 % gilt auch bei kleinen Zahlen
assert.match(lead(32, 31), /aufgerufen – etwa so oft wie in den 30 Tagen davor\.$/)
assert.equal(lead(40, 30), 'Deine Website wurde in den letzten 30 Tagen <b>40-mal</b> aufgerufen – <b>10 Aufrufe mehr</b> als in den 30 Tagen davor.')
assert.equal(lead(31, 20), 'Deine Website wurde in den letzten 30 Tagen <b>31-mal</b> aufgerufen – <b>11 Aufrufe mehr</b> als in den 30 Tagen davor.')
assert.equal(lead(80, 80), 'Deine Website wurde in den letzten 30 Tagen <b>80-mal</b> aufgerufen – etwa so oft wie in den 30 Tagen davor.')
assert.equal(lead(7200, 6857), 'Deine Website wurde in den letzten 30 Tagen <b>7.200-mal</b> aufgerufen – <b>5 % mehr</b> als in den 30 Tagen davor.') // genau 5 % gilt noch als Veränderung
})
test('summarize: ohne Vergleich und neu gestartet', () => {
// previous fehlt: '…in den letzten 30 Tagen <b>X-mal</b> aufgerufen.'
const { previous, ...ohne } = stats()
assert.equal(summarize(ohne).lead, 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen.')
// Vorzeitraum unvollständig, firstDay vor dem Zeitraum: kein Vergleich, aber kein „Seit dem Start“
assert.equal(summarize(stats({ firstDay: '2026-08-20', previous: { ...prev, complete: false } })).lead, 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen.')
// Vorzeitraum zu klein: kein Vergleich
assert.equal(summarize(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 12 } } })).lead, 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen.')
// firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website <b>X-mal</b> aufgerufen.'
const neu = summarize(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } }))
assert.equal(neu.lead, 'Seit dem Start der Analyse am 20.09. wurde deine Website <b>6.930-mal</b> aufgerufen.')
// auch das Startdatum wird maskiert, falls der Collector je Unsinn liefert
assert.ok(!summarize(stats({ firstDay: '<i>x</i>', previous: { ...prev, complete: false } })).lead.includes('<i>'))
// ohne Vergleich kein „Das sind … mehr als zuvor“
assert.equal(neu.items[0].html, '178-mal hat dich jemand direkt kontaktiert, meist per Telefon (96).')
// 7 Tage: „7 Tagen“
assert.match(summarize(stats({ range: 7 })).lead, /in den letzten 7 Tagen .* als in den 7 Tagen davor\.$/)
})
test('summarize: zu wenig Daten → null; keine Kontakte → Hinweis nur ab 30 Besuchern', () => {
assert.equal(summarize(stats({ totals: { ...t, pv: 29 } })), null)
assert.equal(summarize(null), null)
assert.notEqual(summarize(stats({ totals: { ...t, pv: 30 } })), null)
// contacts 0, visitors 40: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.'
const keine = summarize(stats({ totals: { ...t, contacts: 0, visitors: 40 }, contacts: [] }))
assert.deepEqual(keine.items[0], { icon: 'contact', html: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' })
const wenig = summarize(stats({ totals: { ...t, contacts: 0, visitors: 29 }, contacts: [] }))
assert.notEqual(wenig.items[0].icon, 'contact')
})
test('summarize: Kontakte – Vergleichssatz und häufigste Art, Links nach außen zählen nicht mit', () => {
const tel = (n) => ({ key: 'tel', count: n })
const html = (kinds, before) => summarize(stats({
totals: { ...t, contacts: kinds.reduce((a, k) => a + k.count, 0) },
contacts: kinds,
previous: { ...prev, contacts: before, totals: { ...prev.totals, contacts: before.reduce((a, k) => a + k.count, 0) } },
})).items[0].html
assert.equal(html([tel(96)], [tel(108)]), '96-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 12 weniger als zuvor.')
assert.equal(html([{ key: 'mail', count: 100 }], [{ key: 'mail', count: 100 }]), '100-mal hat dich jemand direkt kontaktiert, meist per E-Mail (100). Genauso oft wie zuvor.')
assert.equal(html([{ key: 'wa', count: 5 }], []), '5-mal hat dich jemand direkt kontaktiert, meist per WhatsApp (5).') // Vorwert 0: kein Vergleichssatz
assert.equal(html([tel(5)], [tel(2)]), '5-mal hat dich jemand direkt kontaktiert, meist per Telefon (5). Das sind 3 mehr als zuvor.')
// Links nach außen sind Klicks auf andere Seiten, kein Kontakt: weder in der Zahl noch als häufigste Art, auch nicht im Vorzeitraum
assert.equal(html([{ key: 'out', count: 300 }, tel(40)], [{ key: 'out', count: 900 }, tel(40)]), '40-mal hat dich jemand direkt kontaktiert, meist per Telefon (40). Genauso oft wie zuvor.')
assert.equal(html([{ key: 'out', count: 300 }, tel(40)], [{ key: 'out', count: 900 }]), '40-mal hat dich jemand direkt kontaktiert, meist per Telefon (40).')
// nur Links nach außen (Besucher ≥ 30): wie „keine Kontakte“
const nurOut = summarize(stats({ totals: { ...t, contacts: 300, visitors: 400 }, contacts: [{ key: 'out', count: 300 }] }))
assert.deepEqual(nurOut.items[0], { icon: 'contact', html: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' })
// Collector ohne Kontaktliste: kein Satz, der etwas Falsches behauptet
const { contacts, ...ohneListe } = stats({ totals: { ...t, contacts: 0, visitors: 400 } })
assert.notEqual(summarize(ohneListe).items[0].icon, 'contact')
})
test('summarize: Herkunft, Gerät, Seite und Reihenfolge, höchstens 4', () => {
// refs google.com 880 von 2270 → 'Die meisten, die über einen Link kamen, kommen von Google (880).'
const s = summarize(stats())
const byIcon = Object.fromEntries(s.items.map((i) => [i.icon, i.html]))
assert.equal(byIcon.source, 'Die meisten, die über einen Link kamen, kommen von Google (880).')
// devices m 4590/6930 → '66 % der Aufrufe kommen vom Handy.'
assert.equal(byIcon.device, '66 % der Aufrufe kommen vom Handy.')
// pages / dann /preise → 'Am meisten angesehen: die Startseite, danach /preise (1.310).'
assert.equal(byIcon.page, 'Am meisten angesehen: die Startseite, danach /preise (1.310).')
assert.deepEqual(s.items.map((i) => i.icon), ['contact', 'source', 'device', 'page'])
// Computer, Seite ohne Startseite
const pc = summarize(stats({ devices: [{ key: 'd', count: 600 }, { key: 'm', count: 300 }, { key: 't', count: 100 }], pages: [{ key: '/preise', count: 1310 }, { key: '/', count: 900 }] }))
assert.equal(pc.items.find((i) => i.icon === 'device').html, '60 % der Aufrufe kommen vom Computer.')
assert.equal(pc.items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: /preise (1.310 Aufrufe).')
// nur Startseite
assert.equal(summarize(stats({ pages: [{ key: '/', count: 50 }] })).items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: die Startseite (50 Aufrufe).')
// Gerät unter 55 %, Herkunft unter 20 %: beide Hinweise entfallen
const flach = summarize(stats({ devices: [{ key: 'm', count: 50 }, { key: 'd', count: 50 }], refs: [{ key: 'google.com', count: 10 }, { key: '', count: 90 }] }))
assert.ok(!flach.items.some((i) => i.icon === 'device' || i.icon === 'source'))
// Direkt (leerer Host) zählt nie als Quelle, auch wenn er vorne steht
const direkt = summarize(stats({ refs: [{ key: '', count: 900 }, { key: 'google.com', count: 300 }] }))
assert.equal(direkt.items.find((i) => i.icon === 'source').html, 'Die meisten, die über einen Link kamen, kommen von Google (300).')
// Samstage 2× → Wochentag-Hinweis; alle 5 Bedingungen wahr → Seite fällt weg
const sat = (i, day) => (new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 20 : 10)
const voll = summarize(stats({ series: days('2026-09-05', 30, sat) }))
assert.deepEqual(voll.items.map((i) => i.icon), ['contact', 'day', 'source', 'device'])
assert.equal(voll.items[1].html, 'Samstag ist im Schnitt dein stärkster Tag.')
})
test('summarize: Wochentag und Spitzentag in einem Hinweis', () => {
// Samstage 20, Spitzentag Sa., 19.09. mit 371, sonst 10: beides trifft zu
const both = (i, day) => (day === '2026-09-19' ? 371 : new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 40 : 10)
const s = summarize(stats({ series: days('2026-09-05', 30, both) }))
assert.equal(s.items.find((i) => i.icon === 'day').html, 'Samstag ist im Schnitt dein stärkster Tag. Spitzentag war Sa., 19.09. mit 371 Aufrufen.')
// nur Spitzentag am Mittwoch 23.09.: die anderen Mittwoche (0) gleichen den Wochentagsschnitt aus (alle Wochentage Ø 25)
const spike = (i, day) => (day === '2026-09-23' ? 100 : new Date(day + 'T00:00:00Z').getUTCDay() === 3 ? 0 : 25)
const p = summarize(stats({ series: days('2026-09-05', 30, spike) }))
assert.equal(p.items.find((i) => i.icon === 'day').html, 'Spitzentag war Mi., 23.09. mit 100 Aufrufen.')
// weniger als 70 Aufrufe oder weniger als 7 Tage: kein Wochentag-Hinweis
assert.ok(!summarize(stats({ totals: { ...t, pv: 69 }, series: days('2026-09-05', 30, spike) })).items.some((i) => i.icon === 'day'))
assert.ok(!summarize(stats({ firstDay: '2026-09-30', series: days('2026-09-05', 30, spike) })).items.some((i) => i.icon === 'day'))
})
test('summarize: Wochentag-Hinweis zählt alle Tage der Reihe (alle sind abgeschlossen), auch in der 7-Tage-Ansicht', () => {
const isSat = (day) => new Date(day + 'T00:00:00Z').getUTCDay() === 6
const dayHtml = (s) => s.items.find((i) => i.icon === 'day')?.html
// Reihe 04.09.–03.10.2026 (Ende = gestern, heute steht getrennt in `today`); der letzte Tag, Samstag 03.10., zählt mit
const last = '2026-10-03'
const base = { from: '2026-09-04', to: last, today: { day: '2026-10-04', pv: 5, visitors: 3, contacts: 0 } }
assert.equal(dayHtml(summarize(stats({ ...base, series: days('2026-09-04', 30, (i, day) => (isSat(day) ? 280 : 200)) }))), 'Samstag ist im Schnitt dein stärkster Tag.')
// Spitzentag am letzten Tag der Reihe wird erkannt
const spike = days('2026-09-04', 30, (i, day) => (day === last ? 400 : 100))
assert.equal(dayHtml(summarize(stats({ ...base, series: spike }))), 'Samstag ist im Schnitt dein stärkster Tag. Spitzentag war Sa., 03.10. mit 400 Aufrufen.')
// 7-Tage-Ansicht: genau 7 abgeschlossene Tage reichen für den Hinweis (28.09.–04.10., Sa. 03.10.)
const week = days('2026-09-28', 7, (i, day) => (isSat(day) ? 500 : 100))
assert.equal(dayHtml(summarize(stats({ range: 7, from: '2026-09-28', to: '2026-10-04', series: week }))), 'Samstag ist im Schnitt dein stärkster Tag. Spitzentag war Sa., 03.10. mit 500 Aufrufen.')
// weniger als 7 Tage (neue Website): weiter kein Hinweis
assert.equal(dayHtml(summarize(stats({ range: 7, from: '2026-09-28', to: '2026-10-04', firstDay: '2026-09-29', series: week }))), undefined)
// alter Collector ohne `today`: derselbe Hinweis, kein Absturz
const { today, ...alt } = stats({ ...base, series: days('2026-09-04', 30, (i, day) => (isSat(day) ? 280 : 200)) })
assert.equal(dayHtml(summarize(alt)), 'Samstag ist im Schnitt dein stärkster Tag.')
})
test('summarize: Pfade und Hosts werden maskiert', () => {
const s = summarize(stats({ pages: [{ key: '/<img src=x>', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] }))
assert.ok(!JSON.stringify(s).includes('<img'))
assert.ok(JSON.stringify(s).includes('&lt;img'))
assert.equal(s.items.find((i) => i.icon === 'source').html, 'Die meisten, die über einen Link kamen, kommen von a&quot;b.de (900).')
assert.equal(s.items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: /&lt;img src=x&gt; (900 Aufrufe).')
// auch an zweiter Stelle hinter der Startseite
const z = summarize(stats({ pages: [{ key: '/', count: 900 }, { key: '/<script>', count: 100 }] }))
assert.ok(!JSON.stringify(z).includes('<script>'))
})
test('chartModel mit compare: gemeinsame Skala, prevLine', () => {
const cur = days('2026-09-05', 5, 10)
const before = days('2026-08-06', 5, (i) => (i === 2 ? 400 : 20))
const m = chartModel(cur, { compare: before })
assert.equal(m.top, 500) // über beide Reihen (Maximum 400)
assert.equal(m.points.length, 5)
assert.deepEqual(m.points.map((p) => p.prev), [20, 20, 400, 20, 20])
assert.match(m.prevLine, /^M[\d. ]+( L[\d. ]+){4}$/)
// nach Tagesindex ausgerichtet: gleiche x-Werte wie die aktuelle Linie, y am Vorwert
const first = m.prevLine.split(' L')[0].slice(1).split(' ').map(Number)
assert.equal(first[0], m.points[0].x)
const mid = m.prevLine.split(' L')[2].split(' ').map(Number)
assert.equal(mid[0], m.points[2].x)
assert.ok(mid[1] < first[1]) // 400 liegt höher als 20
// ohne compare: wie bisher, prevLine leer
const o = chartModel(cur)
assert.equal(o.top, 10)
assert.equal(o.prevLine, '')
assert.ok(o.points.every((p) => p.prev === null))
assert.equal(o.line, chartModel(cur, { compare: [] }).line)
assert.equal(chartModel(cur, { compare: [] }).prevLine, '')
// kürzerer Vergleich: nur die vorhandenen Tage, Rest null
const k = chartModel(cur, { compare: before.slice(0, 2) })
assert.deepEqual(k.points.map((p) => p.prev), [20, 20, null, null, null])
assert.match(k.prevLine, /^M[\d. ]+ L[\d. ]+$/)
})
+2 -1
View File
@@ -112,7 +112,7 @@ test('GET /api/funktionen: analyse-Zustand je Projekt, mit und ohne Schalter', a
const body = await get('/api/funktionen', { cookie })
const p = (id) => body.projects.find((x) => x.projectId === id)
assert.equal(p('p1').features.news.state, 'off')
assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] } })
assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] }, activatedAt: null })
assert.equal(p('p2').analyse.eligible, false)
assert.equal(p('p2').analyse.reason, 'nicht_oeffentlich')
assert.equal(p('p2').analyse.reasonText, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich)
@@ -133,6 +133,7 @@ test('Aktivieren und Deaktivieren (Kunde)', async () => {
const view = (await get('/api/funktionen', { cookie })).projects.find((x) => x.projectId === 'p1').analyse
assert.equal(view.state, 'active')
assert.equal(view.accentColor, '#112233')
assert.match(view.activatedAt, /^\d{4}-\d{2}-\d{2}T/)
const off = await post(`${A}/deactivate`, { projectId: 'p1' }, { cookie })
assert.deepEqual(off.body, { ok: true, state: 'off' })
assert.equal(stops.length, 1)
+11 -1
View File
@@ -37,6 +37,13 @@ test('Diagramm-Skalierung', () => {
assert.ok(m.xLabels.length <= 7 && m.xLabels.length >= 2)
assert.equal(chartModel([]).points.length, 0)
assert.equal(chartModel([{ day: '2026-10-01', pv: 3 }]).points.length, 1)
// labelGap: die vorletzte Beschriftung rückt weg, wenn sie der letzten zu nahe kommt (30 Tage: 29.09. ist 79 Einheiten vor 03.10.)
const labels = (opts) => chartModel(series, { width: 640, ...opts }).xLabels.map((l) => l.label)
assert.equal(labels({}).length, 7)
const wide = chartModel(series, { labelGap: 90 }).xLabels
assert.equal(wide.length, 6)
assert.ok(wide.every((l, i, a) => !i || l.x - a[i - 1].x >= 90))
assert.equal(wide[wide.length - 1].label, '30.09.') // letzter Tag bleibt
})
test('Balkenzeilen', () => {
@@ -54,8 +61,11 @@ test('Portal: Analyse-Ansicht ist verdrahtet', () => {
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen'/)
assert.match(html, /id="view-analyse"/)
assert.match(html, /id="analyse-body"/)
assert.match(app, /analyse: \(\) => import\('\/analyse\.js'\)/)
const an = readFileSync(new URL('../public/analyse.js', import.meta.url), 'utf8')
assert.doesNotMatch(an, /\bconfirm\(/)
const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
assert.doesNotMatch(fn.slice(fn.indexOf('Analyse ---')), /\bconfirm\(/)
assert.doesNotMatch(fn, /function renderAnalyseStats|function renderAnalyseHeat/)
})
test('Portal-Vorschau: Banner-Text ist der des Clients', () => {
+34
View File
@@ -4,6 +4,8 @@ import assert from 'node:assert/strict'
import { createAnalyticsClient } from '../server/services/analyticsClient.js'
import { createAdminServer, createPublicServer } from '../analytics/src/server.js'
import { openDb } from '../analytics/src/db.js'
import { computeStats } from '../analytics/src/stats.js'
import { compareFrom, shortDay } from '../public/analyse-view.js'
const SITE = 'abcdef0123456789abcd'
const DOMAIN = 'kunde.example.com'
@@ -39,11 +41,24 @@ test('Vertrag: putSite -> Ereignisse -> getStats -> heatToken -> deleteSite', as
await post({ t: 'ct', path: '/', kind: 'tel' })
await post({ t: 'bn', a: 'accept' })
// Frisch gemessen: heute steht nur in `today`, der Zeitraum sind die abgeschlossenen Tage bis gestern
const jetzt = await client.getStats(SITE, '7')
assert.deepEqual(jetzt.today, { day: jetzt.today.day, pv: 3, visitors: 1, contacts: 1 })
assert.equal(jetzt.totals.pv, 0)
assert.ok(jetzt.today.day > jetzt.to && jetzt.series.every((d) => d.day <= jetzt.to))
// Um 36 Stunden zurückdatieren: liegt immer auf einem abgeschlossenen Tag des 7-Tage-Zeitraums
db.prepare('UPDATE events_raw SET ts = ts - 36 * 3600 * 1000 WHERE site = ?').run(SITE)
const stats = await client.getStats(SITE, '7')
assert.equal(stats.totals.pv, 3)
assert.equal(stats.totals.contacts, 1)
assert.equal(stats.today.pv, 0)
assert.equal(stats.series.length, 7)
assert.equal(stats.series.reduce((n, d) => n + d.pv, 0), 3)
assert.equal(stats.range, 7)
assert.equal(stats.previous.series.length, 7)
assert.ok('firstDay' in stats && 'avgSec' in stats.totals)
assert.ok(stats.series.every((d) => 'visitors' in d && 'contacts' in d && 'avgSec' in d))
assert.deepEqual(stats.pages, [{ key: '/', count: 2 }, { key: '/kontakt', count: 1 }])
assert.deepEqual(stats.refs, [{ key: '', count: 2 }, { key: 'google.com', count: 1 }])
assert.equal(stats.banner.accept, 1)
@@ -72,3 +87,22 @@ test('Vertrag: stopSite setzt active=0 und offSince, unbekannt zaehlt als erledi
assert.equal(db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(KEY2).offSince, row.offSince)
assert.deepEqual(await client.stopSite('00000000000000000000'), { ok: true, missing: true })
})
test('Vertrag: "Vergleich ab" des Portals ist der erste Tag, an dem der Collector den Vorzeitraum als vollständig meldet', () => {
const firstDay = '2026-09-20'
const evDb = openDb(':memory:')
evDb.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(SITE, 'p1', '["a.de"]')
evDb.prepare("INSERT INTO events_raw (site, ts, type, path, vp, country, device) VALUES (?,?,'pv','/','d','DE','d')").run(SITE, Date.UTC(2026, 8, 20, 10))
for (const range of [7, 30]) {
const addDays = (day, n) => new Date(Date.UTC(+day.slice(0, 4), +day.slice(5, 7) - 1, +day.slice(8, 10) + n)).toISOString().slice(0, 10)
const at = (day) => computeStats(evDb, SITE, range, Date.parse(`${day}T10:00:00Z`))
const first = addDays(firstDay, 2 * range) // "Vergleich ab" laut Portal
const dayBefore = at(addDays(first, -1))
assert.equal(dayBefore.previous.complete, false, `range ${range}: am Tag davor noch nicht vollständig`)
assert.deepEqual(compareFrom(dayBefore), { ok: false, from: shortDay(first) })
const onDay = at(first)
assert.equal(onDay.previous.complete, true, `range ${range}: am Tag selbst vollständig`)
assert.equal(onDay.previous.from, firstDay)
assert.equal(compareFrom(onDay).from, '') // kein Datum mehr (der Vorzeitraum ist voll; ok bleibt false, denn es gibt nur 1 Aufruf)
}
})
+70 -9
View File
@@ -17,7 +17,7 @@ const plain = await startAppwriteMock()
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), plain.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { Query, createDocument, createDocuments, getDocument, updateDocument, listDocuments, listAllDocuments, countDocuments } = await import(
const { Query, keinerVon, dokumenteUrl, createDocument, createDocuments, getDocument, updateDocument, listDocuments, listAllDocuments, countDocuments } = await import(
'../server/services/appwriteAdmin.js'
)
@@ -41,7 +41,36 @@ test('Query-Helfer bauen die Appwrite-Abfragen', () => {
assert.deepEqual(Query.cursorAfter('doc1'), { method: 'cursorAfter', values: ['doc1'] })
assert.deepEqual(Query.select(['a', 'b']), { method: 'select', values: ['a', 'b'] })
assert.deepEqual(Query.notEqual('status', 'x'), { method: 'notEqual', attribute: 'status', values: ['x'] })
assert.deepEqual(Query.notEqual('status', ['x', 'y']), { method: 'notEqual', attribute: 'status', values: ['x', 'y'] })
})
// Appwrite 1.8.1 (gemessen 2026-10-05): notEqual mit Liste => HTTP 400 "NotEqual queries require exactly one value."
test('Query.notEqual nimmt genau einen Wert; eine Liste wirft einen TypeError, der keinerVon nennt', () => {
assert.throws(() => Query.notEqual('status', ['x', 'y']), (err) => err instanceof TypeError && /keinerVon/.test(err.message))
assert.throws(() => Query.notEqual('status', []), (err) => err instanceof TypeError && /keinerVon/.test(err.message))
assert.throws(() => Query.notEqual('status', ['x']), TypeError, 'auch eine Liste mit einem Wert: der Aufrufer soll den Wert selbst uebergeben')
})
test('keinerVon baut je Wert eine notEqual-Abfrage mit genau einem Wert (Appwrite schneidet sie)', () => {
assert.deepEqual(keinerVon('status', ['x', 'y', 'z']), [
{ method: 'notEqual', attribute: 'status', values: ['x'] },
{ method: 'notEqual', attribute: 'status', values: ['y'] },
{ method: 'notEqual', attribute: 'status', values: ['z'] },
])
assert.deepEqual(keinerVon('status', []), [])
assert.deepEqual(keinerVon('status', new Set(['x'])), [{ method: 'notEqual', attribute: 'status', values: ['x'] }])
assert.throws(() => keinerVon('status', [['x', 'y']]), TypeError, 'ein Eintrag ist ein einzelner Wert, keine Liste')
})
test('dokumenteUrl: dieselbe Serialisierung wie die Anfrage an Appwrite (eine queries[] je Abfrage, URL-kodiert)', () => {
const url = dokumenteUrl('termineOutbox', [Query.equal('status', 'wartet'), ...keinerVon('art', ['G01', 'G02'])])
const parsed = new URL(url)
assert.match(parsed.pathname, /\/collections\/termineOutbox\/documents$/)
assert.deepEqual(parsed.searchParams.getAll('queries[]').map((q) => JSON.parse(q)), [
{ method: 'equal', attribute: 'status', values: ['wartet'] },
{ method: 'notEqual', attribute: 'art', values: ['G01'] },
{ method: 'notEqual', attribute: 'art', values: ['G02'] },
])
assert.ok(url.includes('queries%5B%5D='), 'die Klammern stehen kodiert in der URL, so zaehlt der nginx sie')
})
test('Bulk-Create legt 3 Dokumente in einer Anfrage an', async () => {
@@ -204,20 +233,52 @@ test('select liefert nur die genannten Felder plus alle $-Felder', async () => {
assert.equal((await getDocument('termineOutbox', 't2_f_sel00')).featureId, 'f_sel')
})
test('notEqual schliesst einen Wert oder alle Werte der Liste aus', async () => {
test('notEqual schliesst einen Wert aus, mehrere notEqual schneiden sich (keinerVon)', async () => {
seedOutbox('f_ne', 4, (i) => ({ status: ['wartet', 'gesendet', 'fehler', 'abgebrochen'][i] }))
const filter = Query.equal('featureId', 'f_ne')
const order = Query.orderAsc('$id')
const without = async (value) => (await listDocuments('termineOutbox', [filter, Query.notEqual('status', value), order])).map((d) => d.status)
const without = async (...werte) => (await listDocuments('termineOutbox', [filter, ...keinerVon('status', werte), order])).map((d) => d.status)
assert.deepEqual(await without('wartet'), ['gesendet', 'fehler', 'abgebrochen'])
assert.deepEqual(await without(['gesendet', 'fehler']), ['wartet', 'abgebrochen'])
assert.deepEqual(await without(['wartet', 'gesendet', 'fehler', 'abgebrochen']), [])
assert.deepEqual(await without(['gibtEsNicht']), ['wartet', 'gesendet', 'fehler', 'abgebrochen'])
// zwei notEqual auf demselben Feld schneiden sich (beide Bedingungen muessen gelten)
assert.deepEqual(await without('gesendet', 'fehler'), ['wartet', 'abgebrochen'])
assert.deepEqual(await without('wartet', 'gesendet', 'fehler', 'abgebrochen'), [])
assert.deepEqual(await without('gibtEsNicht'), ['wartet', 'gesendet', 'fehler', 'abgebrochen'])
assert.deepEqual(await without(), ['wartet', 'gesendet', 'fehler', 'abgebrochen'], 'ohne Werte keine Abfrage')
// von Hand zwei notEqual auf demselben Feld: beide Bedingungen muessen gelten
const both = await listDocuments('termineOutbox', [filter, Query.notEqual('status', 'wartet'), Query.notEqual('status', 'fehler'), order])
assert.deepEqual(both.map((d) => d.status), ['gesendet', 'abgebrochen'])
})
// Appwrite 1.8.1 (M1): HTTP 400 general_query_invalid; der Nachbau lehnt eine Liste genauso ab
test('Nachbau: notEqual mit Liste (oder ohne Wert) => HTTP 400 wie Appwrite, auch ueber den rohen Pfad', async () => {
seedOutbox('f_ne400', 2)
const roh = (values) => JSON.stringify({ method: 'notEqual', attribute: 'status', values })
for (const values of [['a', 'b'], ['a', 'b', 'c'], []]) {
const antwort = await fetch(`${rawDocs(aw, 'termineOutbox')}?queries[]=${encodeURIComponent(roh(values))}`)
assert.equal(antwort.status, 400, JSON.stringify(values))
assert.deepEqual(await antwort.json(), { message: 'Invalid query: NotEqual queries require exactly one value.', code: 400, type: 'general_query_invalid' })
}
// durch appwriteAdmin: derselbe Fehler (status, type), kein Treffer
await assert.rejects(
listDocuments('termineOutbox', [roh(['a', 'b'])]),
(err) => err.status === 400 && err.type === 'general_query_invalid' && /exactly one value/.test(err.message),
)
// ein Wert geht
assert.equal((await listDocuments('termineOutbox', [roh(['a']), Query.equal('featureId', 'f_ne400')])).length, 2)
})
// Appwrite 1.8.1 (M3): SQL-Semantik, ein Dokument ohne Wert (null oder nie gesetzt) passt zu notEqual nicht
test('Nachbau: ein Dokument mit Feld null oder ohne Feld passt nicht zu notEqual', async () => {
aw.seed('termineOutbox', { $id: 't2_f_nl00', featureId: 'f_nl', status: 'wartet' })
aw.seed('termineOutbox', { $id: 't2_f_nl01', featureId: 'f_nl', status: null })
aw.seed('termineOutbox', { $id: 't2_f_nl02', featureId: 'f_nl' })
aw.seed('termineOutbox', { $id: 't2_f_nl03', featureId: 'f_nl', status: 'gesendet' })
const ids = async (...queries) => (await listDocuments('termineOutbox', [Query.equal('featureId', 'f_nl'), ...queries, Query.orderAsc('$id')])).map((d) => d.$id)
assert.deepEqual(await ids(Query.notEqual('status', 'wartet')), ['t2_f_nl03'], 'null und fehlend sind ausgeschlossen')
assert.deepEqual(await ids(Query.notEqual('status', 'gibtEsNicht')), ['t2_f_nl00', 't2_f_nl03'])
assert.deepEqual(await ids(...keinerVon('status', ['wartet', 'gesendet'])), [])
assert.equal(await countDocuments('termineOutbox', [Query.equal('featureId', 'f_nl'), Query.notEqual('status', 'wartet')]), 1)
})
test('total zaehlt alle Treffer, auch ueber limit und cursorAfter hinaus; countDocuments liefert es', async () => {
seedOutbox('f_total', 5)
seedOutbox('f_total_fremd', 3)
@@ -229,7 +290,7 @@ test('total zaehlt alle Treffer, auch ueber limit und cursorAfter hinaus; countD
assert.equal(await totalOf([Query.equal('featureId', 'f_nirgends'), Query.limit(2)]), 0)
assert.equal(await totalOf([filter]), 5, 'ohne limit gilt der Standard von 25, total bleibt die Trefferzahl')
assert.equal(await countDocuments('termineOutbox', [filter]), 5)
assert.equal(await countDocuments('termineOutbox', [filter, Query.notEqual('$id', ['t2_f_total00', 't2_f_total01'])]), 3)
assert.equal(await countDocuments('termineOutbox', [filter, ...keinerVon('$id', ['t2_f_total00', 't2_f_total01'])]), 3)
assert.equal(await countDocuments('termineOutbox', [Query.equal('featureId', 'f_nirgends')]), 0)
// 30 Treffer ueber dem Standardlimit 25: total sagt 30, die Liste hat 25
seedOutbox('f_total_dreissig', 30)
+1 -1
View File
@@ -25,7 +25,7 @@ test('Entwurfsseite ohne Fassung ist nicht mehr als Datei vorhanden', () => {
})
test('Portal: keine alten Formulierungen, AVV-Adresse kommt aus a.legal', () => {
const fn = readFileSync(root('public/funktionen.js'), 'utf8')
const fn = readFileSync(root('public/analyse.js'), 'utf8')
for (const old of ['anonym', 'Cookie-Einstellungen', 'Die bisherigen Daten bleiben noch 60 Tage erhalten', 'Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag', "AVV_URL = '/rechtliches/avv-website-analyse.html'"]) {
assert.ok(!fn.includes(old), old)
}
+128 -3
View File
@@ -16,12 +16,14 @@ useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1'
const { config } = await import('../server/config.js')
const { startTestApp } = await import('./support/testApp.js')
const { legalPaths, legalPdfFiles, legalPdfAttachments, legalRouter } = await import('../server/services/legalDocs.js')
const { render, parse, lauf, termineDokumente, MarkdownFehler } = await import('../scripts/build-legal-html.mjs')
const { render, parse, lauf, termineDokumente, shopDokumente, MarkdownFehler } = await import('../scripts/build-legal-html.mjs')
const V = config.termine.avvVersion
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const lies = (rel) => readFileSync(join(ROOT, rel), 'utf8')
const DOKUMENTE = termineDokumente(V)
const S = config.shop.fassung
const SHOP = shopDokumente(S)
const META = { fassung: '2026-10-05', titel: 'Prüfdokument', seitenkopf: 'Prüfdokument', nav: [{ href: '/dashboard.html', text: 'Portal' }] }
const KOPF = '# Titel\n\n**Fassung 2026-10-05**\n\n'
@@ -215,7 +217,7 @@ test('generator_lehnt_unbekannte_syntax_ab: Codeblock, Zitat, Bild, Link, rohes
assert.throws(() => render('# Titel\n\n**Fassung 2026-10-06**\n', META), /Fassung 2026-10-05/)
// Meta: Fassung im Format, Adressen der Navigation nur relativ oder https
assert.throws(() => render(`${KOPF}Text\n`, { ...META, fassung: '5.10.2026' }), /fassung/)
for (const href of ['javascript:alert(1)', '//evil.example/x', 'http://example.com', '/x"onmouseover="y', 'data:text/html,x']) {
for (const href of ['javascript:alert(1)', '//evil.example/x', 'http://example.com', '/x"onmouseover="y', 'data:text/html,x', '/\\evil.example/x', '/x\\y', 'https://example.com\\@evil.example/']) {
assert.throws(() => render(`${KOPF}Text\n`, { ...META, nav: [{ href, text: 'x' }] }), /Adresse in der Navigation/, href)
}
assert.throws(() => parse(42), TypeError)
@@ -357,5 +359,128 @@ test('script_modus: build-legal-pdfs.sh kennt termine und datenschutz, lässt di
// bash versteht das Skript, und ein unbekannter Modus liefert die Usage mit allen Modi
const syntax = spawnSync('bash', ['-n', 'scripts/build-legal-pdfs.sh'], { cwd: ROOT, encoding: 'utf8' })
assert.equal(syntax.status, 0, syntax.stderr)
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|datenschutz]'))
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|shop|datenschutz]'))
})
// ---------------------------------------------------------------------------------------------------- Webshop (Task J1)
test('shopDokumente: lb und avv mit Quelle, Ziel und Navigation auf die Fassung; die Widerrufsbelehrung gehört zum Satz termine', () => {
assert.deepEqual(SHOP.map((d) => d.doc), ['lb', 'avv'])
assert.deepEqual(
SHOP.map((d) => [d.md, d.html]),
[
['docs/rechtliches/leistungsbeschreibung-webshop.md', `public/rechtliches/leistungsbeschreibung-webshop-${S}.html`],
['docs/rechtliches/avv-webshop.md', `public/rechtliches/avv-webshop-${S}.html`],
],
)
const adressen = Object.values(legalPaths('shop', S))
assert.equal(adressen.length, 3)
for (const d of SHOP) {
assert.equal(d.meta.fassung, S)
const hrefs = d.meta.nav.map((l) => l.href)
for (const a of adressen) assert.ok(hrefs.includes(a), `Navigation von ${d.doc} verlinkt ${a}`)
}
// die Widerrufsbelehrung der Funktionen wird von shop nie erzeugt (sonst änderte shop die Datei des Satzes termine)
assert.ok(!SHOP.some((d) => d.html.includes('widerrufsbelehrung-funktionen')))
assert.equal(legalPaths('shop', S).widerruf, legalPaths('termine', S).widerruf)
// Standard der Fassung ist config.shop.fassung
assert.deepEqual(shopDokumente(), SHOP)
})
test('shop_html_gleich_generator: die eingecheckten Seiten sind genau render(Markdown); deterministisch; --check Exit 0', () => {
for (const d of SHOP) {
const md = lies(d.md)
const html = render(md, d.meta)
assert.equal(lies(d.html), html, `${d.html} weicht vom Generator ab (node scripts/build-legal-html.mjs shop)`)
assert.equal(render(md, d.meta), html, 'deterministisch: zweiter Lauf gleiche Bytes')
assert.doesNotMatch(html.replaceAll(S, ''), /\b\d{4}-\d{2}-\d{2}\b/, `${d.html}: Datum außer der Fassung`)
// Kopf und Seitenhülle wie beim Terminplaner
assert.ok(html.includes(`<strong>Fassung ${S}</strong>`))
assert.ok(html.includes(`<meta name="webklar-fassung" content="${S}">`))
assert.ok(html.includes(`content: "WEBklar · ${d.meta.seitenkopf}"`))
assert.doesNotMatch(html, /<!--|Entwurf|ENTWURF|<script|<iframe|<img|<form|<link|\sstyle=|\ssrc=/, `${d.html}: verbotener Inhalt`)
assert.doesNotMatch(html, /⚖|\bQ\d+\b|\bF\d+\b|\{\{|\}\}|undefined|NaN/, `${d.html}: Marker oder Platzhalter`)
assert.equal([...html.matchAll(/<h1>/g)].length, 1)
const hrefs = [...html.matchAll(/\shref="([^"]*)"/g)].map((m) => m[1])
for (const h of hrefs) assert.ok(/^\/(?!\/)/.test(h) || /^https:\/\//.test(h), `${d.html}: Link ${h}`)
}
const lauf = spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', 'shop', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
assert.equal(lauf.status, 0, lauf.stdout + lauf.stderr)
assert.equal([...lauf.stdout.matchAll(/^ok /gm)].length, 2)
// der Satz termine bleibt unverändert (drei Seiten)
const termine = spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', 'termine', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
assert.equal(termine.status, 0, termine.stdout + termine.stderr)
assert.equal([...termine.stdout.matchAll(/^ok /gm)].length, 3)
})
test('shop_lauf: --check meldet fehlende oder abweichende Seiten mit Exit 1, schreibt nur lb und avv; unbekannter Satz: Exit 2 mit Usage', () => {
const dir = mkdtempSync(join(tmpdir(), 'legalhtml-shop-'))
try {
mkdirSync(join(dir, 'docs/rechtliches'), { recursive: true })
mkdirSync(join(dir, 'public/rechtliches'), { recursive: true })
for (const d of SHOP) cpSync(join(ROOT, d.md), join(dir, d.md))
const zeilen = []
const ruf = (...args) => lauf(args, { root: dir, log: (z) => zeilen.push(z), fehler: (z) => zeilen.push(z) })
assert.equal(ruf('shop', '--check'), 1)
assert.equal(zeilen.filter((z) => z.startsWith('ABWEICHT')).length, 2)
assert.equal(existsSync(join(dir, SHOP[0].html)), false, '--check schreibt nichts')
assert.equal(ruf('shop'), 0)
assert.equal(ruf('shop', '--check'), 0)
for (const d of SHOP) assert.equal(readFileSync(join(dir, d.html), 'utf8'), lies(d.html), 'dieselben Bytes wie die eingecheckte Seite')
assert.equal(existsSync(join(dir, `public/rechtliches/widerrufsbelehrung-funktionen-${S}.html`)), false, 'shop schreibt die Widerrufsbelehrung nicht')
// Quelle geändert, Seite nicht nachgezogen
writeFileSync(join(dir, SHOP[1].md), `${lies(SHOP[1].md)}\n## 13. Neu\n\n(1) Neuer Absatz.\n`)
zeilen.length = 0
assert.equal(ruf('shop', '--check'), 1)
assert.deepEqual(zeilen.filter((z) => z.startsWith('ABWEICHT')), [`ABWEICHT ${SHOP[1].html}`])
// falscher Aufruf
zeilen.length = 0
assert.equal(ruf('analyse'), 2)
assert.equal(ruf('shop', '--schreiben'), 2)
assert.equal(ruf(), 2)
assert.match(zeilen[0], /Aufruf: node scripts\/build-legal-html\.mjs termine\|shop \[--check\]/)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
test('shop_pdf_dateien: je Dokument ein PDF unter dem Namen aus legalPdfFiles (mehr als eine Seite, größer als 20 KB), Prüfsumme der HTML-Seite daneben', () => {
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
const dateien = legalPdfFiles('shop', S)
assert.deepEqual(Object.keys(dateien), ['lb', 'avv', 'widerruf'])
for (const d of SHOP) {
const f = dateien[d.doc]
assert.equal(f.path, join(ROOT, d.html.replace(/\.html$/, '.pdf')))
const bytes = readFileSync(f.path)
assert.equal(bytes.subarray(0, 5).toString('latin1'), '%PDF-', `${d.doc}: kein PDF`)
assert.ok(bytes.length > 20 * 1024, `${d.doc}: PDF mit ${bytes.length} Bytes ist zu klein`)
assert.ok(bytes.subarray(-1024).toString('latin1').includes('%%EOF'), `${d.doc}: PDF ist abgeschnitten`)
const seiten = bytes.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []
assert.ok(seiten.length >= 2, `${d.doc}: ${seiten.length} Seiten`)
const summe = lies(d.html.replace(/\.html$/, '.pdf.sha256'))
assert.match(summe, /^[0-9a-f]{64}\n$/, 'nur die Prüfsumme und ein Zeilenende')
assert.equal(summe.trim(), sha256(readFileSync(join(ROOT, d.html))), `${d.html} wurde geändert, ohne das PDF neu zu drucken (scripts/build-legal-pdfs.sh shop)`)
}
// die Widerrufsbelehrung ist die gemeinsame Datei (PDF und Prüfsumme bestehen)
assert.ok(readFileSync(dateien.widerruf.path).length > 5 * 1024)
assert.match(lies(`public/rechtliches/widerrufsbelehrung-funktionen-${S}.pdf.sha256`), /^[0-9a-f]{64}\n$/)
})
test('shop_script_modus: build-legal-pdfs.sh kennt den Modus shop (Slugs der Registry, Fassung aus config.shop.fassung), all druckt ihn mit, Usage nennt ihn', () => {
const sh = lies('scripts/build-legal-pdfs.sh')
assert.match(sh, /^shop\)$/m)
assert.ok(sh.includes('config.shop.fassung'), 'die Vorgabe der Fassung kommt aus der Konstante des Servers')
assert.ok(sh.includes('SHOP_VERSION'))
const shop = sh.slice(sh.indexOf('\nshop_pdfs() {'), sh.indexOf('\ncase "$MODE"'))
for (const d of SHOP) {
const slug = /\/([a-z-]+)-\d{4}-\d{2}-\d{2}\.html$/.exec(d.html)[1]
assert.ok(shop.includes(slug), `Modus shop baut ${slug}`)
}
assert.ok(!shop.includes('widerrufsbelehrung'), 'die Widerrufsbelehrung druckt der Modus termine')
assert.ok(shop.includes('sha256sum "$quelle"') && shop.includes('echo "$summe" > "$ziel.sha256"') && shop.includes('(unveraendert)'))
assert.match(sh, /^shop\)\n {2}shop_pdfs\n {2};;$/m)
assert.ok(sh.slice(sh.indexOf('\nall)'), sh.indexOf('\n*)')).includes(' shop_pdfs\n'), 'all druckt auch die Webshop-PDFs')
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|shop|datenschutz]'))
const syntax = spawnSync('bash', ['-n', 'scripts/build-legal-pdfs.sh'], { cwd: ROOT, encoding: 'utf8' })
assert.equal(syntax.status, 0, syntax.stderr)
})
+2 -2
View File
@@ -17,10 +17,10 @@ const entries = () => [...aw.docs('customerViewLog').values()]
const seedLog = (id, customerId, createdAt, action = 'geoeffnet') =>
aw.seed('customerViewLog', { $id: id, customerId, staffUserId: 'u_staff2', staffName: 'Andrej', action, detail: '', createdAt })
test('VIEW_ACTIONS: genau die elf Aktionen', () => {
test('VIEW_ACTIONS: genau die zwölf Aktionen', () => {
assert.deepEqual(VIEW_ACTIONS, [
'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht',
'termin_einstellungen', 'termin_aktion', 'termin_manuell', 'zeiten_gespeichert',
'termin_einstellungen', 'termin_aktion', 'termin_manuell', 'shop_aenderung', 'zeiten_gespeichert',
])
})
+100
View File
@@ -0,0 +1,100 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Einmal-Skript scripts/feature-consent-felder-leeren.mjs (C1): Probelauf als Standard, --apply leert IP und Browser nur dort, wo
// featureConsents sie traegt. Nur Nachbauten, nie Produktion.
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), featureConsents: schemaKeys('featureConsents') } })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { run, parseArgs } = await import('../scripts/feature-consent-felder-leeren.mjs')
const feature = (id, over = {}) =>
aw.seed('portalFeatures', {
$id: id, customerId: 'c1', projectId: `p_${id}`, featureKey: 'termine', state: 'active', enabled: true, siteKey: `k_${id}`,
stripeSessionId: `cs_${id}`, consentText: 'Text', consentAt: '2026-10-01T10:00:00.000Z', consentIp: '203.0.113.7', consentUserAgent: 'UA', ...over,
})
const nachweis = (id, over = {}) =>
aw.seed('featureConsents', {
$id: `n_${id}`, customerId: 'c1', projectId: `p_${id}`, featureKey: 'termine', stripeSessionId: `cs_${id}`, acceptedAt: '2026-10-01T10:00:00.000Z',
fassungen: '{}', texte: '[]', textHash: 'x', ip: '203.0.113.7', userAgent: 'UA', konto: 'k@example.invalid', ...over,
})
const angaben = (id) => {
const d = aw.docs('portalFeatures').get(id)
return [d.consentIp, d.consentUserAgent, d.consentText, d.consentAt, d.siteKey]
}
const stumm = () => {
const zeilen = []
return { log: (z) => zeilen.push(z), zeilen }
}
feature('f_ok1'); nachweis('f_ok1') // IP und Browser im Nachweis
feature('f_ok2', { consentUserAgent: '' }); nachweis('f_ok2') // nur IP am Dokument, im Nachweis vorhanden
feature('f_alt'); // Altbestand: kein Nachweis
feature('f_ohne_ip_im_nachweis'); nachweis('f_ohne_ip_im_nachweis', { ip: '' }) // Nachweis ohne IP: traegt sie nicht
feature('f_keine_sitzung', { stripeSessionId: '' }) // ohne Sitzung kein Nachweis
feature('f_leer', { consentIp: '', consentUserAgent: '' }); nachweis('f_leer') // nichts zu leeren
aw.seed('portalFeatures', { $id: 'f_n', customerId: 'c2', projectId: 'p_n', featureKey: 'news', state: 'off', enabled: false }) // Feld nie gesetzt
test('parseArgs: ohne Argument und mit --dry-run ist es der Probelauf, nur --apply schreibt, Unbekanntes und beides zusammen ist ein Fehler', () => {
assert.deepEqual(parseArgs([]), { apply: false })
assert.deepEqual(parseArgs(['--dry-run']), { apply: false })
assert.deepEqual(parseArgs(['--apply']), { apply: true })
assert.throws(() => parseArgs(['--apply', '--dry-run']), /schliessen sich aus/)
assert.throws(() => parseArgs(['--force']), /Unbekanntes Argument: --force/)
})
test('Probelauf (Standard) zaehlt und listet, schreibt aber nichts', async () => {
const vorher = JSON.stringify([...aw.docs('portalFeatures')])
const out = stumm()
const erg = await run({ log: out.log })
assert.equal(erg.applied, false)
assert.equal(erg.total, 7)
assert.equal(erg.withData, 5, 'f_ok1, f_ok2, f_alt, f_ohne_ip_im_nachweis, f_keine_sitzung')
assert.deepEqual(erg.clearable.sort(), ['f_ok1', 'f_ok2'])
assert.deepEqual(erg.kept.sort(), ['f_alt', 'f_keine_sitzung', 'f_ohne_ip_im_nachweis'])
assert.equal(erg.cleared, 0)
assert.equal(JSON.stringify([...aw.docs('portalFeatures')]), vorher, 'nichts geschrieben')
assert.match(out.zeilen.join('\n'), /Probelauf \(--dry-run\): es wird nichts geschrieben/)
assert.match(out.zeilen.join('\n'), /zu leeren .*: 2/)
})
test('--apply leert consentIp und consentUserAgent nur dort, wo der Nachweis sie traegt; Text, Zeit und alles andere bleiben', async () => {
const out = stumm()
const erg = await run({ apply: true, log: out.log })
assert.equal(erg.applied, true)
assert.equal(erg.cleared, 2)
assert.deepEqual(erg.failed, [])
assert.deepEqual(angaben('f_ok1').filter((_, i) => i !== 3), ['', '', 'Text', 'k_f_ok1'])
assert.ok(aw.docs('portalFeatures').get('f_ok1').consentAt, 'consentAt bleibt')
assert.deepEqual(angaben('f_ok2').slice(0, 2), ['', ''])
for (const id of ['f_alt', 'f_ohne_ip_im_nachweis', 'f_keine_sitzung']) assert.equal(aw.docs('portalFeatures').get(id).consentIp, '203.0.113.7', `${id} bleibt`)
// ein zweiter Lauf findet nichts mehr zu leeren
const nochmal = await run({ apply: true, log: () => {} })
assert.deepEqual(nochmal.clearable, [])
assert.equal(nochmal.cleared, 0)
})
test('--apply: ein Schreibfehler steht im Ergebnis, die uebrigen Dokumente werden trotzdem geleert; ein nicht lesbarer Nachweis bleibt unberuehrt', async () => {
feature('f_x1'); nachweis('f_x1')
feature('f_x2'); nachweis('f_x2')
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
const erg = await run({ apply: true, log: () => {} })
assert.equal(erg.failed.length, 1)
assert.match(erg.failed[0].reason, /kaputt/)
assert.equal(erg.cleared, 1)
const offen = ['f_x1', 'f_x2'].filter((id) => aw.docs('portalFeatures').get(id).consentIp)
assert.deepEqual(offen, [erg.failed[0].id])
assert.equal((await run({ apply: true, log: () => {} })).cleared, 1, 'der naechste Lauf holt das fehlgeschlagene Dokument nach')
feature('f_a_y'); nachweis('f_a_y')
aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500, message: 'Nachweis kaputt' })
const out = stumm()
const lesefehler = await run({ apply: true, log: out.log })
assert.deepEqual(lesefehler.unreadable, ['f_a_y'])
assert.equal(lesefehler.cleared, 0, 'nicht lesbar heisst nicht leeren')
assert.equal(aw.docs('portalFeatures').get('f_a_y').consentIp, '203.0.113.7')
})
+38
View File
@@ -149,6 +149,44 @@ test('textstand_pin: Hash über die verbundenen Bestelltexte beider Funktionen s
assert.equal(sha256(texte), TEXTSTAND.sha256, 'Bestelltexte geändert: neues Fassungsdatum und neuen Hash eintragen')
})
/**
* Pin auf die Bestelltexte des Webshops (Haken und Knopf, durch eine Leerzeile verbunden), gebunden an config.shop.fassung.
* Bei Textänderung neues Fassungsdatum (config.shop.fassung) UND neuen Hash: gespeicherte Nachweise tragen Text und Fassung.
*/
const SHOP_TEXTSTAND = { fassung: '2026-10-05', sha256: '09553de56c794602ca462d980de8548015df7b03d90a5ba1227ce83a58a6608a' }
test('shop_haken_woertlich: fünf Haken in fester Reihenfolge mit dem Wortlaut aus dem Plan', () => {
const S = config.shop.fassung
const SHOP = config.features.shop
assert.deepEqual(SHOP.consent.items.map((i) => i.id), ['unterlagen', 'verantwortung', 'stripe', 'beginn', 'information'])
assert.deepEqual(SHOP.consent.items.map((i) => i.haken), [true, true, true, true, true])
assert.deepEqual(SHOP.consent.items.map((i) => i.text), [
`Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${S} zu.`,
'Ich verkaufe als Unternehmer in eigenem Namen. Für meine Produkte, Preise, Produktangaben und Rechtstexte (AGB, Widerrufsbelehrung, Impressum, Datenschutzerklärung) bin ich selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Ich verkaufe keine der in der Leistungsbeschreibung ausgeschlossenen Waren.',
'Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Käufer und erhebt keine Umsatzbeteiligung.',
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Webshops beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).',
'Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Webshops für diese Website: Einbau des Shop-Widgets, Verwaltung im Portal, Muster-Rechtstexte), Gesamtpreis 299 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), voraussichtliche Dauer meist innerhalb von 2–3 Wochen, keine laufende Gebühr von WEBklar; der Webshop läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.',
])
assert.equal(SHOP.consent.knopf, 'Zahlungspflichtig bestellen · 299 €')
for (const item of SHOP.consent.items) assert.deepEqual(Object.keys(item).sort(), ['haken', 'id', 'text'], 'ohne url')
assert.equal(SHOP.consent.version, S)
})
test('shop_textstand_pin: Hash über die verbundenen Bestelltexte des Webshops steht im Test', () => {
const SHOP = config.features.shop
const texte = [...SHOP.consent.items.map((i) => i.text), SHOP.consent.knopf].join('\n\n')
assert.equal(config.shop.fassung, SHOP_TEXTSTAND.fassung, 'neue Fassung: Pin und Hash hier und in den Rechtstext-Tests nachziehen')
assert.equal(sha256(texte), SHOP_TEXTSTAND.sha256, 'Bestelltexte geändert: neues Fassungsdatum und neuen Hash eintragen')
})
test('shop_belehrung_haken: dieselbe Belehrung wie beim Terminplaner, ohne ENTWURF', async () => {
const belehrung = await featureHaken('shop').belehrung()
assert.equal(belehrung, widerrufsbelehrungFunktion())
assert.equal(belehrung, await featureHaken('termine').belehrung())
assert.deepEqual(config.features.shop.widerruf, { version: config.shop.fassung, url: legalPaths('shop', config.shop.fassung).widerruf })
assert.doesNotMatch(JSON.stringify([config.features.shop.consent.items, belehrung]), /ENTWURF/)
})
// ------------------------------------------------------------------------------------------------ Belehrung im Haken
test('belehrung_haken: der Standard-Haken liefert die Widerrufsbelehrung der Funktionen, News hat keinen', async () => {
+17 -4
View File
@@ -103,15 +103,15 @@ afterEach(() => {
// ---------------------------------------------------------------------------------------- Register ---
test('ticketFreischaltungKeys: Register-Schluessel mit ticketFreischaltung, in Registerreihenfolge', () => {
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'zeiten'])
NEWS.ticketFreischaltung = false
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop', 'zeiten'])
NEWS.ticketFreischaltung = 'ja'
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'zeiten'], 'nur true zaehlt')
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop', 'zeiten'], 'nur true zaehlt')
NEWS.ticketFreischaltung = true
TERMINE.ticketFreischaltung = false
try {
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'shop', 'zeiten'])
} finally {
TERMINE.ticketFreischaltung = true
}
@@ -370,6 +370,19 @@ test('Der Haken bekommt das unter der Sperre frisch gelesene Dokument; ein zwisc
assert.deepEqual(gesehen, [{ state: 'setup', marker: 'frisch gelesen' }], 'der Haken sieht nur das frische, noch offene Dokument')
})
test('A3: disable() teilt die Sperre feature-activate:<id> mit der Freischaltung - wer die Freischaltung haelt, laesst das Abschalten warten', async () => {
const id = einrichtung('termine', { einstellung: true })
let abschalten
await withLocks([`feature-activate:${id}`], async () => {
abschalten = sf.disable(id, { reason: 'team' })
await new Promise((r) => setTimeout(r, 80))
assert.equal(zustand(id), 'setup', 'solange die Freischaltung die Sperre haelt, schaltet disable() nicht ab')
})
const doc = await abschalten
assert.equal(doc.state, 'disabled')
assert.equal(zustand(id), 'disabled')
})
// ------------------------------------------------------------------------------- Tickets, Kunden ---
test('Ticket Cancelled (oder Open) => bleibt setup, der Haken wird gar nicht gefragt', async () => {
let gefragt = 0
+31
View File
@@ -372,6 +372,37 @@ test('(7) Stripe-Sitzung laeuft ab, die Zahlung kommt doch: Uebernahme, setup, e
assert.equal(mails.sent.length, 2)
})
test('(7) A1: erneut zugestelltes checkout.session.completed nach der 30-Tage-Loeschung (off, zurueckgesetzt, gleiche Sitzung) wird nicht uebernommen', async (t) => {
const sessionId = await bestelle()
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
// der 30-Tage-Lauf hat das Dokument zurueckgesetzt (siehe setzeFunktionZurueck); die Sitzungs-ID bleibt als Marke stehen
const d0 = await doc()
aw.seed('portalFeatures', {
...d0,
state: 'off',
enabled: false,
unlockedByPurchase: false,
purchaseStatus: 'zurueckgesetzt',
zahlungMeldung: '',
ticketId: '',
adminNote: 'Daten gelöscht (Löschfrist abgelaufen)',
})
const tickets0 = tickets().length
const mails0 = mails.sent.length
const stumm = STUMM(t)
const warn = t.mock.method(console, 'warn', () => {})
// "Resend event" im Stripe-Dashboard: dasselbe Ereignis noch einmal
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
const d = await doc()
assert.deepEqual([d.state, d.purchaseStatus, d.stripeSessionId, d.ticketId], ['off', 'zurueckgesetzt', sessionId, ''], 'nicht uebernommen')
assert.equal(tickets().length, tickets0, 'kein neues Ticket')
assert.equal(mails.sent.length, mails0, 'weder K15 noch T16')
const log = geloggt(stumm) + warn.mock.calls.map((c) => c.arguments.join(' ')).join('\n')
assert.match(log, new RegExp(`Sitzung ${sessionId} .*zurueckgesetzt`))
assert.doesNotMatch(log, /k@example\.invalid|Salon/, 'keine personenbezogenen Daten im Log')
})
// ------------------------------------------------------------------------------------ (8) Neubestellung nach Ablauf ---
test('(8) Neubestellung nach Ablauf: die alte Sitzung zahlt (Uebernahme, ein Ticket), die neue danach ungeklaert, kein zweites Ticket', async (t) => {
const alt = await bestelle()
+207 -3
View File
@@ -4,7 +4,7 @@ import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Register config.features (News + Terminplaner) und featureRegistry.js. Dienste (siteFeatures, ticketAdmin)
// Register config.features (News, Terminplaner, Webshop) und featureRegistry.js. Dienste (siteFeatures, ticketAdmin)
// brauchen die Test-Umgebung vor dem ersten Import von server/config.js.
const aw = await startAppwriteMock()
const st = await startFakeStripe()
@@ -121,11 +121,12 @@ test('FEATURE_LABELS und FEATURE_KEYS unverändert, aus dem Register abgeleitet'
assert.deepEqual(sf.FEATURE_LABELS, {
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
shop: { name: 'Webshop', keine: 'keinen Webshop' },
zeiten: { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' },
})
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, reg.featureKeys())
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'zeiten'])
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop', 'zeiten'])
})
// ---------------------------------------------------------------------------------- Schlüsselschutz ---
@@ -296,3 +297,206 @@ test('Bestelltexte ohne ENTWURF', async () => {
const belehrung = reg.featureHaken('termine').belehrung
if (belehrung) frei(await belehrung(), 'belehrung')
})
// ---------------------------------------------------------------------------------- Webshop (Block A3) ---
test('Shop: Preis, Struktur und Texte', async () => {
const s = reg.featureConfig('shop')
assert.equal(s, config.features.shop)
assert.equal(s.key, 'shop')
assert.equal(s.product, 'shopSetup')
assert.equal(config.shopSetup.amountCents, 29900)
assert.deepEqual(s.label, { name: 'Webshop', kurz: 'Shop', keine: 'keinen Webshop' })
assert.equal(s.priceLabel, '299 €')
assert.equal(s.setupEstimate, 'meist innerhalb von 2–3 Wochen')
assert.equal(s.consent.knopf, 'Zahlungspflichtig bestellen · 299 €')
assert.deepEqual(
s.consent.items.map((i) => i.id),
['unterlagen', 'verantwortung', 'stripe', 'beginn', 'information']
)
assert.deepEqual(
s.consent.items.map((i) => i.haken),
[true, true, true, true, true]
)
for (const item of s.consent.items) {
assert.equal(typeof item.text, 'string')
assert.ok(item.text.length > 0)
assert.equal(item.url, undefined, 'ohne url')
}
const F = config.shop.fassung
assert.equal(s.consent.version, F)
assert.equal(s.consent.items[0].text, `Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${F} zu.`)
assert.match(s.consent.items[3].text, /Einrichtung des Webshops/)
assert.ok(s.consent.items[4].text.includes(`Gesamtpreis 299 € einmalig (${config.ustHinweis})`))
assert.ok(s.consent.items[4].text.includes('meist innerhalb von 2–3 Wochen'))
// Fassung und Adressen folgen config.shop.fassung; die Widerrufsbelehrung ist die der Funktionen
assert.deepEqual(s.leistungsbeschreibung, { version: F, url: `/rechtliches/leistungsbeschreibung-webshop-${F}.html` })
assert.deepEqual(s.avv, { version: F, url: `/rechtliches/avv-webshop-${F}.html` })
assert.deepEqual(s.widerruf, { version: F, url: `/rechtliches/widerrufsbelehrung-funktionen-${F}.html` })
assert.equal(s.returnHash, 'webshop')
assert.equal(s.returnProjekt, true)
assert.equal(s.requiresWartungsAbo, false)
assert.equal(s.ticketFreischaltung, true)
assert.equal(s.disabledMessage, 'Der Webshop ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.')
assert.equal(s.orderedConflictMessage, 'Der Webshop ist für dieses Projekt schon bestellt.')
assert.equal(s.unavailableMessage, 'Der Webshop ist noch nicht verfügbar.')
assert.equal(s.mails.funktion, 'Webshop')
assert.deepEqual(s.mails.formen, { dein: 'Dein Webshop', nom: 'der Webshop', akk: 'den Webshop', gen: 'des Webshops', pron: 'ihn' })
assert.equal(s.mails.knopf, 'Webshop öffnen')
assert.equal(s.mails.anleitung, false)
assert.equal(s.mails.preisHinweis, `einmalig, ${config.ustHinweis}; keine laufende Gebühr von WEBklar, Stripe-Gebühren zahlst Du direkt an Stripe`)
assert.equal(s.mails.danach, 'Danach kannst Du im Portal Artikel anlegen, Dein Stripe-Konto verbinden und den Shop öffnen.')
assert.equal(s.mails.bereit, 'Dein Webshop ist eingebaut. Sobald Stripe-Konto, Artikel und Rechtstexte fertig sind, kannst Du ihn im Portal öffnen.')
assert.equal(s.ticket.topic({ projectName: 'Laden' }), 'Webshop einbauen – Laden')
assert.equal(s.ticket.topic({ subdomain: 'laden' }), 'Webshop einbauen – laden')
assert.equal(typeof s.ticket.details, 'function')
const ansicht = reg.oeffentlicheAnsicht('shop')
assert.equal(ansicht.name, 'Webshop')
assert.equal(ansicht.kurz, 'Shop')
assert.equal(ansicht.priceLabel, '299 €')
assert.equal(ansicht.requiresWartungsAbo, false)
assert.equal(ansicht.returnHash, 'webshop')
assert.deepEqual(ansicht.consent.items.map((i) => i.id), ['unterlagen', 'verantwortung', 'stripe', 'beginn', 'information'])
assert.equal(ansicht.consent.version, F)
assert.deepEqual(ansicht.avv, { version: F, url: `/rechtliches/avv-webshop-${F}.html` })
assert.doesNotMatch(JSON.stringify(ansicht), /ENTWURF/)
// Termine bleibt unberührt
assert.equal(config.features.termine.consent.knopf, 'Zahlungspflichtig bestellen · 149 €')
})
test('Shop: Fassung folgt config.shop.fassung bei jedem Zugriff', () => {
const s = config.features.shop
const vorher = config.shop.fassung
try {
config.shop.fassung = '2099-01-02'
assert.equal(s.consent.version, '2099-01-02')
assert.ok(s.consent.items[0].text.includes('in der Fassung 2099-01-02 zu.'))
assert.deepEqual(s.avv, { version: '2099-01-02', url: '/rechtliches/avv-webshop-2099-01-02.html' })
assert.equal(s.leistungsbeschreibung.url, '/rechtliches/leistungsbeschreibung-webshop-2099-01-02.html')
// null beim Widerruf heißt "keine Unterlage" (Tests setzen so eine Bestellung ohne sie), undefined stellt den Standard her
s.widerruf = null
assert.equal(s.widerruf, null)
s.widerruf = undefined
assert.equal(s.widerruf.version, '2099-01-02')
} finally {
config.shop.fassung = vorher
s.widerruf = undefined
}
assert.equal(s.consent.version, vorher)
assert.ok(s.consent.items[0].text.includes(`in der Fassung ${vorher} zu.`))
})
test('Shop-Haken: verfügbar nach Schalter, Belehrung, kein freischaltbar', async () => {
const haken = reg.featureHaken('shop')
assert.deepEqual(Object.keys(haken).sort(), ['belehrung', 'bestellpruefung', 'unterlagenBereit', 'verfuegbar'])
assert.equal(haken.freischaltbar, undefined)
const vorher = { ...config.shop }
try {
config.shop.mode = 'off'
assert.equal(haken.verfuegbar('a@b.de'), false)
assert.equal(reg.featureSperre('shop', 'a@b.de'), 'Der Webshop ist noch nicht verfügbar.')
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['a@b.de']
assert.equal(haken.verfuegbar('A@B.de'), true)
assert.equal(haken.verfuegbar('x@y.de'), false)
assert.equal(reg.featureSperre('shop', 'a@b.de'), null)
config.shop.mode = 'on'
assert.equal(haken.verfuegbar('x@y.de'), true)
} finally {
Object.assign(config.shop, vorher)
}
// dieselbe Belehrung wie beim Terminplaner (gleiche Quelle)
assert.equal(await haken.belehrung(), await reg.featureHaken('termine').belehrung())
assert.equal(typeof (await haken.belehrung()), 'string')
})
test('Shop-Haken: Bestellprüfung verlangt die angenommene Anfrage', async () => {
const { bestellpruefung } = reg.featureHaken('shop')
const OFFEN = ['Deine Webshop-Anfrage ist noch nicht freigegeben.']
aw.seed('customers', { $id: 'sc1', name: 'Shop Eins' })
aw.seed('websiteProjects', { $id: 'sp1', customerId: 'sc1', projectName: 'Laden' })
// kein portalFeatures-Dokument
assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), OFFEN)
aw.seed('portalFeatures', { $id: 'f_sp1_shop', customerId: 'sc1', projectId: 'sp1', featureKey: 'shop', siteKey: SITE_KEY, state: 'off', enabled: false })
// portalFeatures-Dokument, aber kein shopConfig
assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), OFFEN)
for (const status of ['', 'offen', 'abgelehnt', 'zurueckgezogen']) {
aw.seed('shopConfig', { $id: 'f_sp1_shop', customerId: 'sc1', projectId: 'sp1', siteKey: SITE_KEY, anfrageStatus: status })
assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), OFFEN, `Status "${status}"`)
}
aw.seed('shopConfig', { $id: 'f_sp1_shop', customerId: 'sc1', projectId: 'sp1', siteKey: SITE_KEY, anfrageStatus: 'angenommen' })
assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), [])
// ein fremder Kunde sieht die Anfrage nicht
assert.deepEqual(await bestellpruefung({ customerId: 'sc2', projectId: 'sp1' }), OFFEN)
})
test('Shop-Haken: Unterlagen sind bereit, sobald die Dateien da sind (bis Block J: nicht)', async () => {
const { unterlagenBereit } = reg.featureHaken('shop')
const { featureLegalReady, legalPaths } = await import('../server/services/legalDocs.js')
const { existsSync } = await import('node:fs')
const F = config.shop.fassung
const dateien = Object.values(legalPaths('shop', F)).flatMap((p) => [p, p.replace(/\.html$/, '.pdf')])
const alleDa = dateien.every((p) => existsSync(new URL(`../public${p}`, import.meta.url)))
assert.equal(await featureLegalReady('shop'), alleDa)
assert.equal(await unterlagenBereit(), alleDa)
})
test('Shop-Ticket: Details deterministisch, mit Snippet und Schritten', async () => {
aw.seed('shopConfig', {
$id: 'f_sp9_shop',
customerId: 'sc9',
projectId: 'sp9',
siteKey: SITE_KEY,
anfrage: JSON.stringify({ warenart: 'kleidung', beschreibung: 'Streng geheim', artikelzahl: 'bis100', versand: true, abholung: true, nachricht: 'Hallo' }),
anfrageStatus: 'angenommen',
})
const doc = { $id: 'f_sp9_shop', customerId: 'sc9', projectId: 'sp9', siteKey: SITE_KEY, featureKey: 'shop' }
const ctx = { feature: config.features.shop, doc, project: { projectName: 'Laden', liveDomain: 'laden.de' }, customer: { name: 'Frau Muster' } }
const text = await config.features.shop.ticket.details(ctx)
assert.equal(typeof text, 'string')
for (const teil of [
'Funktion: Webshop',
'Projekt: Laden',
'Domain: laden.de',
'Kunde: Frau Muster',
`siteKey: ${SITE_KEY}`,
'Anfrage: Warenart: Kleidung, Lieferung: Versand und Abholung, Artikelzahl: bis 100',
`<div data-webklar-shop="${SITE_KEY}"></div>`,
'<script src="https://project.webklar.com/embed/shop.js" defer></script>',
`https://project.webklar.com/shop/${SITE_KEY}/widerruf`,
`https://project.webklar.com/shop/${SITE_KEY}`,
'docs/funktionen/shop-datenschutz-absatz.md',
'Zeit im Arbeitsblatt',
'Verweis: ',
'/customers/sc9?tab=funktionen&anleitung=sp9',
]) {
assert.ok(text.includes(teil), `fehlt: ${teil}`)
}
// freier Text der Anfrage gehört nicht ins Ticket; deterministisch
assert.ok(!text.includes('Streng geheim'))
assert.equal(await config.features.shop.ticket.details(ctx), text)
assert.ok(text.length <= reg.TICKET_DETAILS_MAX)
// ohne Anfrage oder bei Lesefehler: dieselben Teile, nur ohne Anfrage-Zeile; wirft nie
const ohne = await config.features.shop.ticket.details({ ...ctx, doc: { ...doc, $id: 'f_unbekannt_shop' } })
assert.ok(!ohne.includes('Anfrage:'))
assert.ok(ohne.includes(`data-webklar-shop="${SITE_KEY}"`))
aw.seed('shopConfig', { $id: 'f_sp8_shop', customerId: 'sc9', projectId: 'sp8', siteKey: SITE_KEY, anfrage: '{kaputt' })
const kaputt = await config.features.shop.ticket.details({ ...ctx, doc: { ...doc, $id: 'f_sp8_shop' } })
assert.ok(!kaputt.includes('Anfrage:'))
const notfall = await config.features.shop.ticket.details(null)
assert.equal(typeof notfall, 'string')
assert.ok(notfall.length > 0)
})
test('Shop-Ticket über ticketTexte: Betreff und Details', async () => {
const doc = { $id: 'f_sp7_shop', customerId: 'sc7', projectId: 'sp7', siteKey: SITE_KEY, featureKey: 'shop' }
const { topic, details } = await reg.ticketTexte({ feature: config.features.shop, doc, project: { projectName: 'Laden' }, customer: null })
assert.equal(topic, 'Webshop einbauen – Laden')
assert.ok(details.includes(`data-webklar-shop="${SITE_KEY}"`))
})
+12 -10
View File
@@ -25,7 +25,7 @@ const { ANALYSE_ATTRIBUTES } = await import('../scripts/analyse-setup.mjs')
const CONSENT_KEYS = [
'customerId', 'projectId', 'featureKey', 'stripeSessionId', 'acceptedAt', 'fassungen', 'texte', 'textHash', 'ip', 'userAgent', 'konto',
]
const CONSENT_INDEX_KEYS = ['idx_session', 'idx_project_feature']
const CONSENT_INDEX_KEYS = ['idx_session', 'idx_project_feature', 'idx_customer']
// Ohne Angaben ist featureConsents komplett vorhanden (Attribute und Indizes): die Faelle fuer portalFeatures bleiben so
// unberuehrt; die Faelle fuer featureConsents setzen consents/consentAttrs/indexes selbst.
@@ -177,10 +177,11 @@ test('Schema featureConsents: 11 Schluessel mit Typ und Groesse, alle optional',
assert.ok(nach.textHash.size >= 64 && nach.customerId.size >= 36 && nach.projectId.size >= 36)
})
test('Schema featureConsents: beide Indizes mit den Attributen aus der Vorgabe', () => {
test('Schema featureConsents: drei Indizes mit den Attributen aus der Vorgabe (idx_customer für die Löschfristen)', () => {
assert.deepEqual(FEATURE_CONSENT_INDEXES, [
{ key: 'idx_session', type: 'key', attributes: ['stripeSessionId'] },
{ key: 'idx_project_feature', type: 'key', attributes: ['projectId', 'featureKey', 'acceptedAt'] },
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
])
const keys = new Set(CONSENT_KEYS)
for (const index of FEATURE_CONSENT_INDEXES) for (const attr of index.attributes) assert.ok(keys.has(attr), `${index.key}: ${attr}`)
@@ -199,7 +200,7 @@ test('schemaKeys(featureConsents) liefert dieselben Schluessel, der Nachbau nimm
await assert.rejects(() => createDocument(config.collections.featureConsents, { gibtEsNicht: 1 }, 'fc_schema2'), /Unknown attribute/)
})
test('main legt bei fehlender Collection genau featureConsents mit 11 Attributen und 2 Indizes an', async () => {
test('main legt bei fehlender Collection genau featureConsents mit 11 Attributen und 3 Indizes an', async () => {
const client = mockClient({ attrs: KEYS, consents: false, consentAttrs: [], indexes: [] })
const log = []
const result = await main({ client, log: (zeile) => log.push(zeile) })
@@ -210,17 +211,17 @@ test('main legt bei fehlender Collection genau featureConsents mit 11 Attributen
assert.equal(client.calls.attrs.find((a) => a.key === 'texte').size, 20000)
assert.ok(client.calls.attrs.every((a) => a.required === false))
assert.deepEqual(client.calls.waits, CONSENT_KEYS.map((key) => `featureConsents.${key}`))
assert.deepEqual(client.calls.indexes, ['featureConsents.idx_session', 'featureConsents.idx_project_feature'])
assert.deepEqual(client.calls.indexes, ['featureConsents.idx_session', 'featureConsents.idx_project_feature', 'featureConsents.idx_customer'])
// Reihenfolge: Collection, alle Attribute, Warten auf alle Attribute, erst dann die Indizes
const order = client.calls.order
assert.equal(order[0], 'collection featureConsents')
assert.equal(order.findIndex((e) => e.startsWith('index ')), order.length - 2, 'die Indizes kommen zuletzt')
assert.equal(order.findIndex((e) => e.startsWith('index ')), order.length - 3, 'die Indizes kommen zuletzt')
assert.ok(order.lastIndexOf('wait featureConsents.konto') < order.indexOf('index featureConsents.idx_session'))
assert.ok(order.indexOf('attribute featureConsents.konto') < order.indexOf('wait featureConsents.customerId'))
assert.equal(result.missingCollection, true)
assert.equal(result.missing.length, 11)
assert.ok(result.missing.every((a) => a.collection === 'featureConsents'))
assert.deepEqual(result.missingIndexes.map((i) => i.key), ['idx_session', 'idx_project_feature'])
assert.deepEqual(result.missingIndexes.map((i) => i.key), ['idx_session', 'idx_project_feature', 'idx_customer'])
assert.ok(log.includes('+ Collection featureConsents'))
assert.ok(log.includes('+ Index featureConsents.idx_project_feature'))
})
@@ -238,7 +239,7 @@ test('main ergaenzt an vorhandener Collection nur fehlende Attribute und Indizes
const client = mockClient({
attrs: KEYS,
consentAttrs: CONSENT_KEYS.filter((key) => key !== 'konto' && key !== 'textHash'),
indexes: ['idx_session'],
indexes: ['idx_session', 'idx_customer'],
})
const result = await main({ client, log: () => {} })
assert.deepEqual(client.calls.collections, [], 'die Collection wird nicht noch einmal angelegt')
@@ -272,17 +273,18 @@ test('main --check schreibt nie, meldet featureConsents FEHLT und liefert die Lu
for (const key of CONSENT_KEYS) assert.ok(log.includes(`- featureConsents.${key} FEHLT`), key)
assert.ok(log.includes('- featureConsents.idx_session FEHLT'))
assert.ok(log.includes('- featureConsents.idx_project_feature FEHLT'))
assert.ok(log.includes('- featureConsents.idx_customer FEHLT'))
assert.ok(!log.some((zeile) => zeile.includes('portalFeatures') && zeile.includes('FEHLT')))
assert.ok(!log.some((zeile) => zeile.startsWith('+')))
assert.equal(result.missingCollection, true)
assert.deepEqual(result.missing.map((a) => a.key), CONSENT_KEYS)
assert.equal(result.missingIndexes.length, 2)
assert.equal(result.missingIndexes.length, 3)
assert.equal(hatLuecken(result), true)
assert.equal(log.at(-1), '14 Eintrag/Eintraege fehlen.')
assert.equal(log.at(-1), '15 Eintrag/Eintraege fehlen.')
})
test('main --check meldet je fehlendes Attribut und je fehlenden Index einer vorhandenen Collection', async () => {
const client = mockClient({ attrs: KEYS, consentAttrs: CONSENT_KEYS.filter((key) => key !== 'texte'), indexes: ['idx_project_feature'] })
const client = mockClient({ attrs: KEYS, consentAttrs: CONSENT_KEYS.filter((key) => key !== 'texte'), indexes: ['idx_project_feature', 'idx_customer'] })
const log = []
const result = await main({ check: true, client, log: (zeile) => log.push(zeile) })
assert.deepEqual(client.calls, KEINE)
+82
View File
@@ -0,0 +1,82 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { installFakeDom } from './support/fakeDom.js'
import { h } from '../public/termine-dom.js'
import * as bestellen from '../public/funktion-bestellen.js'
// public/funktion-bestellen.js, Task G1: Checkout-Pfad und Zusammenfassung sind Parameter (key, zusammenfassung); ohne sie bleibt alles
// beim Terminplaner. Ohne Server und ohne den schweren Fragebogen-Test: die Bestellung ist von Hand gebaut.
const URL_ZAHLUNG = 'https://checkout.stripe.test/c/pay/cs_test_9'
const mitDom = (optionen, fn) => async () => {
const fx = installFakeDom(optionen)
try {
await fn(fx)
} finally {
fx.restore()
}
}
const bestellung = () => ({
preis: '299 €',
preisHinweis: 'einmalig, ohne Umsatzsteuer gemäß § 19 UStG',
consent: { version: 'v-shop', knopf: 'Zahlungspflichtig bestellen', items: [{ id: 'a', text: 'Ich stimme zu.', haken: true }, { id: 'b', text: 'Hinweis', haken: false }] },
})
const daten = () => ({ kundenansicht: false, offen: [], bestellung: bestellung(), einstellung: {}, fragebogen: {}, branchen: [] })
async function bestelleUndGibLog(fx, optionen) {
const wurzel = h('div')
document.body.append(wurzel)
bestellen.renderBestellen(wurzel, { ctx: { projectId: 'p1', aktuell: () => true }, daten: daten(), ...optionen })
await fx.klick(fx.finde(wurzel, 'input[type="checkbox"]'))
await fx.klick(fx.finde(wurzel, '[data-aktion="bestellen"]'))
await fx.ruhe()
return { wurzel, log: fx.fetchLog }
}
test('renderBestellen mit key "shop": Checkout geht an /api/funktionen/shop/checkout, Körper unverändert', mitDom({ antworten: [{ body: { url: URL_ZAHLUNG } }] }, async (fx) => {
const { log } = await bestelleUndGibLog(fx, { key: 'shop' })
assert.deepEqual(log, [{ method: 'POST', url: '/api/funktionen/shop/checkout', body: { projectId: 'p1', zustimmung: { version: 'v-shop', haken: ['a'] } } }])
assert.equal(fx.window.location.href, URL_ZAHLUNG)
}))
test('renderBestellen ohne key: weiter /api/funktionen/termine/checkout; ein unzulässiger key fällt darauf zurück', mitDom({ antworten: [{ body: { url: URL_ZAHLUNG } }] }, async (fx) => {
const { log } = await bestelleUndGibLog(fx, {})
assert.equal(log[0].url, '/api/funktionen/termine/checkout')
}))
test('renderBestellen: ein key mit Pfadzeichen wird nie in den Pfad übernommen', mitDom({ antworten: [{ body: { url: URL_ZAHLUNG } }] }, async (fx) => {
const { log } = await bestelleUndGibLog(fx, { key: '../abo/x?y' })
assert.equal(log[0].url, '/api/funktionen/termine/checkout')
}))
test('renderBestellen: ohne Zusammenfassungsfunktion (key shop) kein Zusammenfassungsblock; mit Funktion werden ihre Gruppen gezeichnet', mitDom({}, async (fx) => {
const ohne = h('div')
document.body.append(ohne)
bestellen.renderBestellen(ohne, { ctx: { projectId: 'p1' }, daten: daten(), key: 'shop' })
assert.equal(fx.finde(ohne, '.tm-bestellen-zusammenfassung'), null)
assert.ok(fx.finde(ohne, '[data-aktion="bestellen"]'), 'Preis, Zustimmungen und Knopf bleiben')
const mit = h('div')
document.body.append(mit)
const aufrufe = []
bestellen.renderBestellen(mit, {
ctx: { projectId: 'p1' }, daten: daten(), key: 'shop',
zusammenfassung: (...args) => { aufrufe.push(args.length); return [{ schritt: 'artikel', titel: 'Artikel', zeilen: ['3 Artikel'] }] },
})
assert.equal(aufrufe.length, 1)
assert.ok(fx.finde(mit, '.tm-bestellen-zusammenfassung'))
assert.match(fx.text(fx.finde(mit, '.tm-bestellen-gruppe')), /Artikel/)
}))
test('statusKarte: key und name steuern Fortsetzen-Pfad und Texte; ohne Angaben bleibt es der Terminplaner', mitDom({ antworten: [{ body: { url: URL_ZAHLUNG } }] }, async (fx) => {
const shop = bestellen.statusKarte({ projekt: { projectId: 'p1' }, bestellung: { state: 'setup' }, key: 'shop', name: 'Webshop' })
assert.match(fx.text(shop), /Wir richten deinen Webshop ein/)
assert.doesNotMatch(fx.text(shop), /Terminplaner/)
const termine = bestellen.statusKarte({ projekt: { projectId: 'p1' }, bestellung: { state: 'setup' } })
assert.match(fx.text(termine), /Wir richten deinen Terminplaner ein/)
const offen = bestellen.statusKarte({ projekt: { projectId: 'p1' }, bestellung: { state: 'ordered' }, key: 'shop', name: 'Webshop' })
document.body.append(offen)
await fx.klick(fx.finde(offen, '[data-aktion="fortsetzen"]'))
await fx.ruhe()
assert.deepEqual(fx.fetchLog, [{ method: 'POST', url: '/api/funktionen/shop/checkout', body: { projectId: 'p1', fortsetzen: true } }])
}))
+3 -2
View File
@@ -656,8 +656,9 @@ test('Nachweis steht vor der Weiterleitung: Serverfassungen und -texte in Reihen
assert.ok(vor <= n.acceptedAt && n.acceptedAt <= nach, `${vor} <= ${n.acceptedAt} <= ${nach}`)
assert.match(n.acceptedAt, /Z$/, 'UTC-Zeit mit Z')
assert.equal(doc.consentText, hakenTexte(T).slice(0, 1000), 'consentText aus den Haken-Texten')
assert.equal(doc.consentIp, n.ip)
assert.equal(doc.consentUserAgent, n.userAgent)
// C1: IP und Browser stehen nur im Nachweis (featureConsents), nicht mehr am Funktionsdokument
assert.equal(doc.consentIp, '')
assert.equal(doc.consentUserAgent, '')
})
test('Belehrung im Nachweis: Eintrag belehrung (haken false) direkt vor dem Knopf, News hat keinen', async () => {
+1 -1
View File
@@ -109,8 +109,8 @@ test('bestellseite_hinweis: GET /api/funktionen liefert ustHinweis', async () =>
const dir = mkdtempSync(join(tmpdir(), 'fn-recht-'))
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
const clientQuelle = lesen('public/funktionen.js')
.replace("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`)
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
.replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`)
const clientDatei = join(dir, 'funktionen.mjs')
writeFileSync(clientDatei, clientQuelle)
+22 -4
View File
@@ -67,19 +67,22 @@ test('liste_zustaende', async () => {
// Terminplaner: zweite Funktion je Projekt, Schalter TERMINE_ENABLED (Vorgabe off) - Einzelheiten in funktionenTermine.test.js
for (const p of body.projects) assert.equal(p.features.termine.state, 'off')
assert.deepEqual(body.termine, { verfuegbar: false })
// Webshop: Schalter SHOP_ENABLED (Vorgabe off) ⇒ nur das Flag, keine weiteren Felder
assert.deepEqual(body.shop, { verfuegbar: false })
// Oeffnungszeiten live: Schalter ZEITEN_ENABLED (Vorgabe off): genau { verfuegbar: false } und je Projekt ohne Status, leer
// (Angebot, Ansicht und Status: test/zeitenEinschalten.test.js)
assert.deepEqual(body.zeiten, { verfuegbar: false })
for (const p of body.projects) assert.deepEqual([p.features.zeiten.state, p.zeitenStatus, p.zeitenLeer], ['off', null, true])
// Register-Ansicht (B12): Preise und Bestelltexte beider Funktionen aus config.features, auch bei termine.verfuegbar: false
assert.deepEqual(Object.keys(body.features).sort(), ['news', 'termine', 'zeiten'])
assert.deepEqual(Object.keys(body.features).sort(), ['news', 'shop', 'termine', 'zeiten'])
assert.equal(body.features.shop.priceLabel, '299 €')
assert.equal(body.features.news.priceLabel, '30 €')
assert.equal(body.features.termine.priceLabel, '149 €')
assert.equal(body.ustHinweis, config.ustHinweis)
assert.ok(typeof body.ustHinweis === 'string' && body.ustHinweis.length > 0)
// Items vollstaendig: Kennung, Wortlaut und Haken jedes Registerpunkts, dazu Fassung und Knopf
for (const key of ['news', 'termine']) {
for (const key of ['news', 'termine', 'shop']) {
const eintrag = config.features[key]
const ansicht = body.features[key]
assert.equal(ansicht.key, key)
@@ -115,7 +118,7 @@ test('liste_legal: legal.termine mit Fassung, https-Links mit Fassung und der Be
const { legalPaths } = await import('../server/services/legalDocs.js')
const V = config.termine.avvVersion
const body = await get('/api/funktionen', { cookie })
assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'termine', 'zeiten'])
assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'shop', 'termine', 'zeiten'])
assert.equal(body.legal.news, null)
// die Oeffnungszeiten haben keine Rechtsdokumente zum Anhaengen (avv und widerruf null): kein Eintrag wie beim Terminplaner
assert.equal(body.legal.zeiten, null)
@@ -136,6 +139,20 @@ test('liste_legal: legal.termine mit Fassung, https-Links mit Fassung und der Be
assert.deepEqual(body.features.termine.avv, { version: V, url: adressen.avv })
assert.deepEqual(body.features.termine.leistungsbeschreibung, { version: V, url: adressen.lb })
assert.deepEqual(body.features.news.widerruf, null)
// Webshop: eigene Fassung (config.shop.fassung), eigene Slugs, dieselbe Belehrung der Funktionen
const S = config.shop.fassung
const shop = body.legal.shop
assert.deepEqual(Object.keys(shop).sort(), ['belehrung', 'links', 'version'])
assert.equal(shop.version, S)
assert.deepEqual(Object.keys(shop.links), ['lb', 'avv', 'widerruf'])
const shopAdressen = legalPaths('shop', S)
for (const [doc, link] of Object.entries(shop.links)) assert.equal(link, `${config.publicBaseUrl}${shopAdressen[doc]}`)
assert.ok(shop.links.lb.includes('leistungsbeschreibung-webshop-'))
assert.ok(shop.links.avv.includes('avv-webshop-'))
assert.ok(shop.links.widerruf.includes('widerrufsbelehrung-funktionen-'))
assert.equal(shop.belehrung, widerrufsbelehrungFunktion())
assert.deepEqual(body.features.shop.avv, { version: S, url: shopAdressen.avv })
})
test('liste_register_ohne_freigabe: features.termine steht auch bei Schalter off und Pilot ohne Teilnahme', async () => {
@@ -194,7 +211,8 @@ test('checkout_legt_session_an', async () => {
assert.equal(doc.stripeSessionId, session.id)
assert.equal(doc.consentText, config.funktionen.consentText)
assert.ok(doc.consentAt)
assert.ok(doc.consentUserAgent)
assert.equal(doc.consentUserAgent, '', 'C1: der Browser steht nur im Nachweis, nicht am Funktionsdokument')
assert.equal(doc.consentIp, '')
// zweite Bestellung waehrend die erste offen ist: dieselbe Zahlungsseite, keine neue Sitzung
const again = await checkout('p1')
assert.equal(again.status, 200)
+315
View File
@@ -0,0 +1,315 @@
import { test, after } 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 { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Webshop: Aktivierung durch das Team nur mit Zustimmung des Kunden in Textform (AVV Webshop, Vorbemerkung Abs. 2; wie der Terminplaner,
// siehe test/funktionenTermine.test.js "Staff-Aktivierung mit Zustimmung in Textform"). Schalter: shopTextformNoetig (services/shop/schalter.js).
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post } = await import('./support/testApp.js')
const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const sf = await import('../server/services/siteFeatures.js')
const { leseZustimmung } = await import('../server/services/featureConsents.js')
const { setLegalDirForTests, legalPdfFiles } = await import('../server/services/legalDocs.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const { shopTextformNoetig } = await import('../server/services/shop/schalter.js')
const app = await startTestApp({ '/api/team': teamRoutes })
const mails = mailSink()
setMailTransportForTests(mails)
const MODUS = { mode: config.shop.mode, pilotEmails: [...config.shop.pilotEmails] }
after(() => {
config.shop.mode = MODUS.mode
config.shop.pilotEmails = MODUS.pilotEmails
setLegalDirForTests(null)
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } }
const shopMit = (mode, pilotEmails = []) => {
config.shop.mode = mode
config.shop.pilotEmails = pilotEmails
}
const team = (customerId, key, body, opts = STAFF) => post(`/api/team/funktionen/${customerId}/${key}`, body, opts)
const featuresOf = (res, projectId) => res.body.projects.find((p) => p.projectId === projectId).features
const berlinTag = (ms = Date.now()) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(ms)
const tagMitVersatz = (tag, tage) => new Date(Date.parse(`${tag}T12:00:00Z`) + tage * 86400000).toISOString().slice(0, 10)
const ttmmjjjj = (tag) => tag.split('-').reverse().join('.')
const consents = (projectId) => [...aw.docs('featureConsents').values()].filter((c) => c.projectId === projectId)
const k16An = (email) => mails.sent.filter((m) => m.subject === 'Dein Webshop ist bereit' && m.to.some((t) => t.address === email))
let nr = 0
/** Kunde mit eigenem Projekt (ohne Funktionsdokument); E-Mail frei waehlbar. */
function legeKunde({ email } = {}) {
nr += 1
const customerId = `c_shoptf_${nr}`
const projectId = `p_shoptf_${nr}`
aw.seed('customers', { $id: customerId, email: email ?? `shoptf-${nr}@example.invalid`, name: `Kunde Shop ${nr}`, status: 'active', portalAccessEnabled: true })
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Shop-Projekt ${nr}` })
return { customerId, projectId, email: aw.docs('customers').get(customerId).email }
}
const aktivieren = (k, body = {}, opts = STAFF) => team(k.customerId, 'shop', { projectId: k.projectId, action: 'activate', ...body }, opts)
const feld = (over = {}) => ({ textformDate: berlinTag(), textformChannel: 'chat', ...over })
test('shopTextformNoetig: nur im Modus on und nur außerhalb der Pilotliste; „*“ und fehlende Adresse befreien nicht', (t) => {
t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails))
shopMit('off', ['a@example.invalid'])
assert.equal(shopTextformNoetig('jemand@example.com'), false)
shopMit('pilot', [])
assert.equal(shopTextformNoetig('jemand@example.com'), false)
shopMit('on', ['kenso.gri@gmail.com'])
assert.equal(shopTextformNoetig('jemand@example.com'), true)
assert.equal(shopTextformNoetig(' KENSO.gri@gmail.com '), false, 'Pilotadresse, Schreibweise egal')
assert.equal(shopTextformNoetig('kenso.gri@gmail.com.evil.de'), true)
shopMit('on', ['*'])
assert.equal(shopTextformNoetig('jemand@example.com'), true, 'der Stern zählt in on nicht als Pilotadresse')
assert.equal(shopTextformNoetig('*'), true)
for (const email of [undefined, null, '', ' ']) assert.equal(shopTextformNoetig(email), true, `ohne Adresse: ${JSON.stringify(email)}`)
})
test('shop textform: ohne Angaben 400, mit gültigen Angaben 200; Nachweis mit Fassung, Datum, Weg, Erklärungen, Mitarbeiter; K16 mit LB und AVV als PDF', async (t) => {
t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails))
shopMit('on', ['pilot@example.invalid'])
const k = legeKunde()
const v = config.shop.fassung
const mailsVorher = mails.sent.length
const ohne = await aktivieren(k)
assert.equal(ohne.status, 400)
assert.match(ohne.body.error, /Datum der Zustimmung/)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'shop'), null, 'nichts angelegt')
assert.equal(consents(k.projectId).length, 0)
assert.equal(mails.sent.length, mailsVorher)
const nurDatum = await aktivieren(k, { textformDate: berlinTag() })
assert.equal(nurDatum.status, 400)
assert.match(nurDatum.body.error, /Weg der Zustimmung/)
const tag = berlinTag()
const ok = await aktivieren(k, { textformDate: tag, textformChannel: 'email' })
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.equal(featuresOf(ok, k.projectId).shop.state, 'active')
const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'shop')
assert.deepEqual([doc.state, doc.enabled, doc.unlockedByPurchase], ['active', true, false], 'ohne Bestellung freigeschaltet')
assert.match(doc.adminNote, /Andrej: activate/, 'Name des Mitarbeiters in der Notiz')
assert.equal(consents(k.projectId).length, 1)
const [nachweis] = consents(k.projectId)
assert.deepEqual([nachweis.customerId, nachweis.projectId, nachweis.featureKey], [k.customerId, k.projectId, 'shop'])
assert.equal(nachweis.stripeSessionId, '', 'keine Stripe-Sitzung')
assert.equal(nachweis.ip, '')
assert.equal(nachweis.userAgent, '')
assert.equal(nachweis.konto, 'Mitarbeiter: Andrej')
assert.ok(Math.abs(Date.parse(nachweis.acceptedAt) - Date.now()) < 10000, 'Zeitpunkt des Einschaltens')
const gelesen = leseZustimmung(nachweis)
assert.deepEqual(gelesen.fassungen, { leistungsbeschreibung: v, avv: v })
assert.deepEqual(gelesen.texte.map((x) => x.id), ['textform', 'unterlagen', 'verantwortung', 'stripe'])
assert.equal(
gelesen.texte[0].text,
`Der Kunde hat am ${ttmmjjjj(tag)} per E-Mail in Textform erklärt, dass er der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop (Fassung ${v}) zustimmt und die folgenden Erklärungen der Bestelltexte der Fassung ${v} abgibt.`
)
// Wortlaut der Erklärungen = die Bestelltexte der Fassung (ohne Widerrufsverlangen und Pflichtinformationen)
const { featureConfig } = await import('../server/services/featureRegistry.js')
const items = featureConfig('shop').consent.items
for (const id of ['unterlagen', 'verantwortung', 'stripe']) {
assert.equal(gelesen.texte.find((x) => x.id === id).text, items.find((i) => i.id === id).text)
}
assert.ok(gelesen.texte[1].text.includes(`Fassung ${v}`))
assert.match(nachweis.textHash, /^[0-9a-f]{64}$/)
// Weg Chat heißt „Chat im Kundenportal“
const chat = legeKunde()
assert.equal((await aktivieren(chat, { textformDate: tag, textformChannel: 'chat' })).status, 200)
assert.ok(leseZustimmung(consents(chat.projectId)[0]).texte[0].text.includes(`am ${ttmmjjjj(tag)} per Chat im Kundenportal in Textform`))
// K16 an den Kunden mit genau den beiden PDFs (Leistungsbeschreibung, AVV), keine Widerrufsbelehrung
const k16 = k16An(k.email)
assert.equal(k16.length, 1, mails.sent.map((m) => m.subject).join(' / '))
assert.deepEqual(k16[0].attachments.map((a) => a.filename), [`Leistungsbeschreibung-Webshop-${v}.pdf`, `AVV-Webshop-${v}.pdf`])
for (const a of k16[0].attachments) {
assert.equal(a.contentType, 'application/pdf')
assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF')
}
assert.equal(mails.sent.length, mailsVorher + 2, 'je eine K16 für den Kunden und den Chat-Kunden, sonst nichts')
})
test('shop textform: scheitert der Versand von K16, bleibt der Webshop freigeschaltet, adminNote und warnung:true sagen es', async (t) => {
t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails))
t.mock.method(console, 'error', () => {})
shopMit('on', [])
const gut = legeKunde()
const ok = await aktivieren(gut, feld())
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.equal(ok.body.warnung, undefined)
const k = legeKunde()
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } })
t.after(() => setMailTransportForTests(mails))
const res = await aktivieren(k, feld())
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.warnung, true)
const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'shop')
assert.equal(doc.state, 'active', 'die Freischaltung bleibt')
assert.equal(consents(k.projectId).length, 1, 'der Nachweis bleibt')
assert.ok(doc.adminNote.includes('K16 mit Unterlagen NICHT gesendet – von Hand nachsenden'), doc.adminNote)
})
test('shop textform entfällt: Modus off und pilot, Modus on mit Pilotadresse, schon aktiv, Abschalten und News brauchen keine Angaben und schreiben keinen Nachweis', async (t) => {
t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails))
const mailsVorher = mails.sent.length
for (const [mode, pilot] of [['off', []], ['pilot', ['*']], ['pilot', []]]) {
shopMit(mode, pilot)
const k = legeKunde()
const res = await aktivieren(k)
assert.equal(res.status, 200, `${mode}: ${JSON.stringify(res.body)}`)
assert.equal(featuresOf(res, k.projectId).shop.state, 'active')
assert.equal(consents(k.projectId).length, 0, mode)
}
const pilotKunde = legeKunde({ email: 'Pilot-Shop@example.invalid' })
shopMit('on', ['pilot-shop@example.invalid'])
assert.equal((await aktivieren(pilotKunde)).status, 200)
assert.equal(consents(pilotKunde.projectId).length, 0)
// Modus on, andere Adresse: Pflicht; Abschalten und News bleiben frei; ein schon aktiver Webshop braucht nichts mehr
const k = legeKunde()
assert.equal((await aktivieren(k)).status, 400)
const news = await team(k.customerId, 'news', { projectId: k.projectId, action: 'activate' })
assert.equal(news.status, 200)
const aktiv = legeKunde()
shopMit('off')
assert.equal((await aktivieren(aktiv)).status, 200)
shopMit('on', [])
assert.equal((await aktivieren(aktiv)).status, 200, 'schon aktiv: keine neue Zustimmung')
assert.equal(consents(aktiv.projectId).length, 0)
const aus = await team(aktiv.customerId, 'shop', { projectId: aktiv.projectId, action: 'disable' })
assert.equal(aus.status, 200, 'Abschalten braucht keine Angaben')
assert.equal(featuresOf(aus, aktiv.projectId).shop.state, 'disabled')
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'shop'), null)
assert.equal(mails.sent.slice(mailsVorher).filter((m) => m.subject === 'Dein Webshop ist bereit').length, 0, 'ohne Textform keine K16')
// ein bezahlter Webshop (Zustimmung des Checkouts liegt vor) braucht keine Zustimmung in Textform
const bezahlt = legeKunde()
aw.seed('portalFeatures', { $id: `f_${bezahlt.projectId}_shop`, customerId: bezahlt.customerId, projectId: bezahlt.projectId, featureKey: 'shop', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' })
const res = await aktivieren(bezahlt)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(featuresOf(res, bezahlt.projectId).shop.state, 'active')
assert.equal(consents(bezahlt.projectId).length, 0)
})
test('shop textform: Datum vor der Fassung, in der Zukunft, ungültig oder fehlend und ungültiger Weg ergeben 400 ohne Nachweis, ohne Dokument, ohne Mail', async (t) => {
t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails))
shopMit('on', [])
const k = legeKunde()
const v = config.shop.fassung
const heute = berlinTag()
const mailsVorher = mails.sent.length
for (const [body, muster] of [
[{ textformDate: tagMitVersatz(v, -1), textformChannel: 'chat' }, /liegt vor der Fassung/],
[{ textformDate: tagMitVersatz(heute, 1), textformChannel: 'chat' }, /in der Zukunft/],
[{ textformDate: '2026-02-30', textformChannel: 'chat' }, /fehlt oder ist ungültig/],
[{ textformDate: 20261005, textformChannel: 'chat' }, /fehlt oder ist ungültig/],
[{ textformDate: heute }, /Weg der Zustimmung/],
[{ textformDate: heute, textformChannel: 'telefon' }, /Weg der Zustimmung/],
[{ textformDate: heute, textformChannel: ['chat'] }, /Weg der Zustimmung/],
]) {
const res = await aktivieren(k, body)
assert.equal(res.status, 400, JSON.stringify(body))
assert.match(res.body.error, muster)
}
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'shop'), null)
assert.equal(consents(k.projectId).length, 0)
assert.equal(mails.sent.length, mailsVorher)
// die Fassung selbst als Datum gilt (nicht davor)
assert.equal((await aktivieren(k, { textformDate: v, textformChannel: 'email' })).status, 200)
})
test('shop textform: fremdes Projekt und Kundensitzung ändern nichts; nicht lesbare oder leere PDFs ergeben 503 vor jeder Änderung', async (t) => {
t.after(() => {
shopMit(MODUS.mode, MODUS.pilotEmails)
setLegalDirForTests(null)
})
shopMit('on', [])
const k = legeKunde()
const fremd = legeKunde()
assert.equal((await team(k.customerId, 'shop', { projectId: fremd.projectId, action: 'activate', ...feld() })).status, 403)
const alsKunde = await aktivieren(k, feld(), { cookie: customerCookie(k.customerId) })
assert.ok(alsKunde.status === 401 || alsKunde.status === 403)
assert.equal(consents(k.projectId).length + consents(fremd.projectId).length, 0)
const echt = legalPdfFiles('shop', config.shop.fassung)
const kopien = fs.mkdtempSync(path.join(os.tmpdir(), 'shoptf-pdf-'))
fs.mkdirSync(path.join(kopien, 'rechtliches'))
t.after(() => fs.rmSync(kopien, { recursive: true, force: true }))
const kopiere = () => {
for (const f of Object.values(echt)) fs.copyFileSync(f.path, path.join(kopien, 'rechtliches', path.basename(f.path)))
}
setLegalDirForTests(kopien)
const mailsVorher = mails.sent.length
// gar keine Dateien
let res = await aktivieren(k, feld())
assert.equal(res.status, 503)
assert.match(res.body.error, /Rechtsunterlagen/)
assert.match(res.body.error, /Webshop/)
for (const [doc, inhalt] of [['lb', ''], ['avv', '<html>kein PDF</html>']]) {
kopiere()
fs.writeFileSync(path.join(kopien, 'rechtliches', path.basename(echt[doc].path)), inhalt)
res = await aktivieren(k, feld())
assert.equal(res.status, 503, `${doc}: ${JSON.stringify(inhalt)}`)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'shop'), null, 'nichts angelegt')
assert.equal(consents(k.projectId).length, 0, 'kein Nachweis')
assert.equal(mails.sent.length, mailsVorher)
}
kopiere()
res = await aktivieren(k, feld())
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(consents(k.projectId).length, 1)
assert.equal(k16An(k.email).length, 1)
})
test('shop textform: adminActivate verweigert Textform für andere Funktionen (news), der Terminplaner-Nachweis bleibt wie bisher', async () => {
const k = legeKunde()
await assert.rejects(
sf.adminActivate(k.customerId, k.projectId, 'news', { by: 'Andrej', adminNote: 'x', textform: { date: berlinTag(), channel: 'chat' } }),
(err) => err.status === 400 && /nur für den Terminplaner und den Webshop/.test(err.message)
)
assert.equal(sf.textformFassung('shop'), config.shop.fassung)
assert.equal(sf.textformFassung('termine'), config.termine.avvVersion)
assert.match(sf.textformNachweisText({ date: '2026-10-05', channel: 'chat' }, '2026-10-05'), /^Der Kunde hat der Leistungsbeschreibung Terminplaner/)
})
test('GET /api/team/funktionen/:kunde liefert shop { textformNoetig, avvVersion, links } je Modus; termine bleibt', async (t) => {
t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails))
const k = legeKunde({ email: 'get-shoptf@example.invalid' })
const v = config.shop.fassung
const feldVon = async () => (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).shop
shopMit('off')
assert.equal((await feldVon()).textformNoetig, false)
shopMit('pilot', ['*'])
assert.equal((await feldVon()).textformNoetig, false)
shopMit('on', ['get-shoptf@example.invalid'])
assert.equal((await feldVon()).textformNoetig, false, 'Pilotadresse')
shopMit('on', [])
const f = await feldVon()
assert.deepEqual(Object.keys(f).sort(), ['avvVersion', 'links', 'textformNoetig'])
assert.equal(f.textformNoetig, true)
assert.equal(f.avvVersion, v)
assert.deepEqual(Object.keys(f.links).sort(), ['avv', 'lb'])
assert.equal(f.links.lb, `${config.publicBaseUrl}/rechtliches/leistungsbeschreibung-webshop-${v}.html`)
assert.equal(f.links.avv, `${config.publicBaseUrl}/rechtliches/avv-webshop-${v}.html`)
const ganz = await get(`/api/team/funktionen/${k.customerId}`, STAFF)
assert.ok(Array.isArray(ganz.projects))
assert.deepEqual(Object.keys(ganz.termine).sort(), ['avvVersion', 'links', 'textformNoetig'])
shopMit('on', ['*'])
assert.equal((await get('/api/team/funktionen/c_gibtsnicht', STAFF)).shop.textformNoetig, true)
})
+328 -2
View File
@@ -8,7 +8,7 @@ import { schemaKeys } from './support/schema.js'
// Terminplaner als zweite Funktion (Aufgabe 17): Team-Route /:customerId/:key, GET /api/funktionen mit Schalter,
// Kunde loeschen schaltet den Terminplaner ab und entfernt seine Entwuerfe (C6). News verhaelt sich wie bisher (test/funktionenRoutes.test.js).
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), termineConfig: schemaKeys('termineConfig'), termineBookings: schemaKeys('termineBookings') },
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), termineConfig: schemaKeys('termineConfig'), termineBookings: schemaKeys('termineBookings'), featureConsents: schemaKeys('featureConsents') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
@@ -295,7 +295,7 @@ test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher'
assert.equal(pt1.features.news.state, 'disabled')
const pt2 = body.projects.find((p) => p.projectId === 'pt2')
assert.deepEqual([pt2.features.termine.state, pt2.features.news.state], ['off', 'off'])
assert.deepEqual(Object.keys(pt2.features), ['news', 'termine', 'zeiten'])
assert.deepEqual(Object.keys(pt2.features), ['news', 'termine', 'shop', 'zeiten'])
// Abschaltgrund und Loeschfrist (E2, R105): News ist hier vom Team abgeschaltet: Grund und Zeitpunkt ja, ein Loeschdatum nein
// (der 30-Tage-Loeschlauf loescht nur Terminplaner-Daten); der aktive Terminplaner und das leere Projekt haben nichts
const aus = pt1.features.news
@@ -1363,3 +1363,329 @@ test('Teamansicht: hindernis je Terminplaner in setup, null in jedem anderen Zus
assert.equal(view.state, 'setup')
assert.equal(view.ticketMissing, false)
})
// ------------------------------------------------------------- Staff-Aktivierung mit Zustimmung in Textform (TP2 F13a) ---
const { zustimmungFuerSitzung, leseZustimmung } = await import('../server/services/featureConsents.js')
const { setLegalDirForTests } = await import('../server/services/legalDocs.js')
const berlinTagF13 = (ms = Date.now()) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(ms)
const tagMitVersatzF13 = (tag, tage) => new Date(Date.parse(`${tag}T12:00:00Z`) + tage * 86400000).toISOString().slice(0, 10)
const ttmmjjjjF13 = (tag) => tag.split('-').reverse().join('.')
const consentsF13 = () => [...aw.docs('featureConsents').values()]
const consentsDesF13 = (projectId) => consentsF13().filter((c) => c.projectId === projectId)
let f13Nr = 0
/** Kunde mit eigenem Projekt (ohne Funktionsdokument); E-Mail frei waehlbar, das Wartungs-Abo laeuft. */
function legeKundeF13({ email, abo = true } = {}) {
f13Nr += 1
const customerId = `c_f13_${f13Nr}`
const projectId = `p_f13_${f13Nr}`
aw.seed('customers', { $id: customerId, email: email ?? `f13-${f13Nr}@example.invalid`, name: `Kunde F13 ${f13Nr}`, status: 'active', portalAccessEnabled: true })
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt F13 ${f13Nr}` })
if (abo) aw.seed('subscriptions', subscriptionDoc(`sub_f13_${f13Nr}`, { ...wartungDoc, customerId }))
return { customerId, projectId, email: aw.docs('customers').get(customerId).email }
}
const aktivierenF13 = (k, body = {}, opts = STAFF) => team(k.customerId, 'termine', { projectId: k.projectId, action: 'activate', ...body }, opts)
const feldF13 = (k, over = {}) => ({ textformDate: berlinTagF13(), textformChannel: 'chat', ...over })
const k16AnF13 = (email) => mails.sent.filter((m) => m.subject === 'Dein Terminplaner ist bereit' && m.to.some((t) => t.address === email))
test('textform_pflicht_modus_on: ohne Angaben 400, mit gültigen Angaben 200, Nachweis mit Datum, Weg, Fassung, Mitarbeiter und leerer Sitzung, K16 mit LB und AVV als PDF', async (t) => {
modusMerken(t)
termineMit('on', ['pilot@example.invalid'])
const k = legeKundeF13()
const v = config.termine.avvVersion
const mailsVorher = mails.sent.length
const ohne = await aktivierenF13(k)
assert.equal(ohne.status, 400)
assert.match(ohne.body.error, /Datum der Zustimmung/)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null, 'nichts angelegt')
assert.equal(consentsDesF13(k.projectId).length, 0)
assert.equal(mails.sent.length, mailsVorher)
// nur Datum ohne Weg
const nurDatum = await aktivierenF13(k, { textformDate: berlinTagF13() })
assert.equal(nurDatum.status, 400)
assert.match(nurDatum.body.error, /Weg der Zustimmung/)
const tag = berlinTagF13()
const ok = await aktivierenF13(k, { textformDate: tag, textformChannel: 'email' })
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.equal(featuresOf(ok, k.projectId).termine.state, 'active')
const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'termine')
assert.deepEqual([doc.state, doc.enabled, doc.unlockedByPurchase], ['active', true, false], 'ohne Bestellung freigeschaltet')
const [nachweis] = consentsDesF13(k.projectId)
assert.equal(consentsDesF13(k.projectId).length, 1)
assert.deepEqual([nachweis.customerId, nachweis.projectId, nachweis.featureKey], [k.customerId, k.projectId, 'termine'])
assert.equal(nachweis.stripeSessionId, '', 'keine Stripe-Sitzung')
assert.equal(nachweis.ip, '')
assert.equal(nachweis.userAgent, '')
assert.equal(nachweis.konto, 'Mitarbeiter: Andrej')
assert.ok(Math.abs(Date.parse(nachweis.acceptedAt) - Date.now()) < 10000, 'Zeitpunkt der Erfassung')
const gelesen = leseZustimmung(nachweis)
assert.deepEqual(gelesen.fassungen, { leistungsbeschreibung: v, avv: v })
assert.deepEqual(gelesen.texte, [{
id: 'textform',
text: `Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung ${v}) am ${ttmmjjjjF13(tag)} per E-Mail in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.`,
}])
assert.match(nachweis.textHash, /^[0-9a-f]{64}$/)
// der Weg Chat heißt „Chat im Kundenportal“
const chat = legeKundeF13()
assert.equal((await aktivierenF13(chat, { textformDate: tag, textformChannel: 'chat' })).status, 200)
assert.ok(leseZustimmung(consentsDesF13(chat.projectId)[0]).texte[0].text.includes(`am ${ttmmjjjjF13(tag)} per Chat im Kundenportal in Textform`))
// K16 an den Kunden mit genau den beiden PDFs (Leistungsbeschreibung, AVV), keine Widerrufsbelehrung
const k16 = k16AnF13(k.email)
assert.equal(k16.length, 1, mails.sent.map((m) => m.subject).join(' / '))
assert.deepEqual(k16[0].attachments.map((a) => a.filename), [`Leistungsbeschreibung-Terminplaner-${v}.pdf`, `AVV-Terminplaner-${v}.pdf`])
for (const a of k16[0].attachments) {
assert.equal(a.contentType, 'application/pdf')
assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF')
}
assert.equal(mails.sent.length, mailsVorher + 2, 'je eine K16 für den Kunden und den Chat-Kunden, sonst nichts')
})
const K16_NICHT_GESENDET = 'K16 mit Unterlagen NICHT gesendet – von Hand nachsenden'
test('textform A2: scheitert der Versand von K16 mit den Unterlagen, bleibt die Funktion freigeschaltet, adminNote und warnung:true sagen es; Erfolg und Kunde ohne Adresse', async (t) => {
modusMerken(t)
termineMit('on', [])
t.mock.method(console, 'error', () => {})
// Erfolg: keine Warnung, keine Notiz
const gut = legeKundeF13()
const ok = await aktivierenF13(gut, feldF13(gut))
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.equal(ok.body.warnung, undefined, 'ohne Fehler kein Feld warnung')
assert.doesNotMatch((await sf.getFeatureDoc(gut.customerId, gut.projectId, 'termine')).adminNote, /NICHT gesendet/)
// Versandfehler: Freischaltung und Nachweis stehen, die Warnung kommt zurueck
const k = legeKundeF13()
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } })
t.after(() => setMailTransportForTests(mails))
const res = await aktivierenF13(k, feldF13(k))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.warnung, true)
const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'termine')
assert.equal(doc.state, 'active', 'die Freischaltung bleibt')
assert.equal(consentsDesF13(k.projectId).length, 1, 'der Nachweis bleibt')
assert.ok(doc.adminNote.includes(K16_NICHT_GESENDET), doc.adminNote)
assert.match(doc.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z System: K16 mit Unterlagen NICHT gesendet – von Hand nachsenden \| .*Andrej: activate/)
assert.equal(featuresOf(res, k.projectId).termine.adminNote, doc.adminNote, 'das Ticketsystem liest die Notiz mit der Antwort')
// Kunde ohne E-Mail-Adresse: K16 kann nicht raus, auch das ist eine Warnung
setMailTransportForTests(mails)
const leer = legeKundeF13({ email: '' })
aw.docs('customers').get(leer.customerId).email = ''
const ohneAdresse = await aktivierenF13(leer, feldF13(leer))
assert.equal(ohneAdresse.status, 200, JSON.stringify(ohneAdresse.body))
assert.equal(ohneAdresse.body.warnung, true)
assert.ok((await sf.getFeatureDoc(leer.customerId, leer.projectId, 'termine')).adminNote.includes(K16_NICHT_GESENDET))
})
test('textform_entfaellt: Modus off und pilot, Modus on mit Pilotadresse, News und Abschalten brauchen keine Angaben und schreiben keinen Nachweis', async (t) => {
modusMerken(t)
const mailsVorher = mails.sent.length
for (const [mode, pilot] of [['off', []], ['pilot', ['*']], ['pilot', []]]) {
termineMit(mode, pilot)
const k = legeKundeF13()
const res = await aktivierenF13(k)
assert.equal(res.status, 200, `${mode}: ${JSON.stringify(res.body)}`)
assert.equal(featuresOf(res, k.projectId).termine.state, 'active')
assert.equal(consentsDesF13(k.projectId).length, 0, mode)
}
// Modus on, aber die Adresse steht in der Pilotliste
const pilotKunde = legeKundeF13({ email: 'Pilot-F13@example.invalid' })
termineMit('on', ['pilot-f13@example.invalid'])
assert.equal((await aktivierenF13(pilotKunde)).status, 200)
assert.equal(consentsDesF13(pilotKunde.projectId).length, 0)
// Modus on, andere Adresse: Pflicht; News und Abschalten bleiben frei
const k = legeKundeF13()
assert.equal((await aktivierenF13(k)).status, 400)
const news = await team(k.customerId, 'news', { projectId: k.projectId, action: 'activate' })
assert.equal(news.status, 200)
assert.equal(featuresOf(news, k.projectId).news.state, 'active')
assert.equal((await team(k.customerId, 'news', { projectId: k.projectId, action: 'disable' })).status, 200)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null)
const aktiv = legeKundeF13()
termineMit('off')
assert.equal((await aktivierenF13(aktiv)).status, 200)
termineMit('on', [])
const aus = await team(aktiv.customerId, 'termine', { projectId: aktiv.projectId, action: 'disable' })
assert.equal(aus.status, 200, 'Abschalten braucht keine Angaben')
assert.equal(featuresOf(aus, aktiv.projectId).termine.state, 'disabled')
assert.equal(consentsF13().filter((c) => [k.projectId, aktiv.projectId, pilotKunde.projectId].includes(c.projectId)).length, 0)
assert.equal(k16AnF13('k@example.invalid').length, 0)
assert.equal(mails.sent.slice(mailsVorher).filter((m) => m.subject === 'Dein Terminplaner ist bereit').length, 0, 'ohne Textform keine K16 (wie bisher)')
})
test('textform_entfaellt: ein bezahlter oder schon aktiver Terminplaner braucht keine neue Zustimmung in Textform (der Nachweis liegt schon vor)', async (t) => {
modusMerken(t)
termineMit('on', [])
// bezahlt (Zustimmung beim Checkout), Zustand setup, Wartungs-Abo läuft
const bezahlt = legeKundeF13()
aw.seed('portalFeatures', { $id: `f_${bezahlt.projectId}_termine`, customerId: bezahlt.customerId, projectId: bezahlt.projectId, featureKey: 'termine', siteKey: 'qrstuvwxyzabcdef', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' })
const res = await aktivierenF13(bezahlt)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(featuresOf(res, bezahlt.projectId).termine.state, 'active')
assert.equal(consentsDesF13(bezahlt.projectId).length, 0)
// schon aktiv (Doppelklick im WOMS): kein zweiter Nachweis, keine zweite Mail
const k = legeKundeF13()
termineMit('off')
assert.equal((await aktivierenF13(k)).status, 200)
termineMit('on', [])
const vorher = mails.sent.length
assert.equal((await aktivierenF13(k)).status, 200)
assert.equal(consentsDesF13(k.projectId).length, 0)
assert.equal(mails.sent.length, vorher)
})
test('textform_datum: Datum vor der Fassung, in der Zukunft, ungültig oder fehlend und ungültiger Weg ergeben 400 ohne Nachweis, ohne Dokument, ohne Mail', async (t) => {
modusMerken(t)
termineMit('on', [])
const k = legeKundeF13()
const v = config.termine.avvVersion
const heute = berlinTagF13()
const mailsVorher = mails.sent.length
const faelle = [
[{ textformDate: tagMitVersatzF13(v, -1), textformChannel: 'chat' }, /liegt vor der Fassung/],
[{ textformDate: tagMitVersatzF13(heute, 1), textformChannel: 'chat' }, /in der Zukunft/],
[{ textformDate: '2026-02-30', textformChannel: 'chat' }, /fehlt oder ist ungültig/],
[{ textformDate: '05.10.2026', textformChannel: 'chat' }, /fehlt oder ist ungültig/],
[{ textformDate: 20261005, textformChannel: 'chat' }, /fehlt oder ist ungültig/],
[{ textformDate: heute }, /Weg der Zustimmung/],
[{ textformDate: heute, textformChannel: 'telefon' }, /Weg der Zustimmung/],
[{ textformDate: heute, textformChannel: '' }, /Weg der Zustimmung/],
[{ textformDate: heute, textformChannel: ['chat'] }, /Weg der Zustimmung/],
]
for (const [body, meldung] of faelle) {
const res = await aktivierenF13(k, body)
assert.equal(res.status, 400, JSON.stringify(body))
assert.match(res.body.error, meldung, JSON.stringify(body))
}
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null, 'nichts angelegt')
assert.equal(consentsDesF13(k.projectId).length, 0)
assert.equal(mails.sent.length, mailsVorher)
// genau am Tag der Fassung und heute ist erlaubt
assert.equal((await aktivierenF13(k, { textformDate: v, textformChannel: 'email' })).status, 200)
assert.equal(consentsDesF13(k.projectId).length, 1)
})
test('textform_pflicht_modus_on: fremdes Projekt, Kundensitzung und nicht lesbare Rechtsunterlagen ändern nichts (die Berechtigung des Team-Wegs bleibt)', async (t) => {
modusMerken(t)
termineMit('on', [])
const k = legeKundeF13()
const fremd = legeKundeF13()
// Projekt eines anderen Kunden: 403 vor jeder Prüfung der Zustimmung
const res = await team(k.customerId, 'termine', { projectId: fremd.projectId, action: 'activate', ...feldF13(k) })
assert.equal(res.status, 403)
const alsKunde = await aktivierenF13(k, feldF13(k), { cookie: customerCookie(k.customerId) })
assert.ok(alsKunde.status === 401 || alsKunde.status === 403)
assert.equal(consentsF13().filter((c) => [k.projectId, fremd.projectId].includes(c.projectId)).length, 0)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null)
// PDFs nicht lesbar: nichts wird freigeschaltet, nichts gespeichert, keine Mail
const leer = await (await import('node:fs/promises')).mkdtemp((await import('node:path')).join((await import('node:os')).tmpdir(), 'f13-leer-'))
setLegalDirForTests(leer)
t.after(() => setLegalDirForTests(null))
const mailsVorher = mails.sent.length
const fehler = await aktivierenF13(k, feldF13(k))
assert.ok(fehler.status >= 500, `Status ${fehler.status}`)
assert.match(fehler.body.error, /Rechtsunterlagen/)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null)
assert.equal(consentsDesF13(k.projectId).length, 0)
assert.equal(mails.sent.length, mailsVorher)
setLegalDirForTests(null)
assert.equal((await aktivierenF13(k, feldF13(k))).status, 200)
})
test('textform_pflicht_modus_on: eine leere oder keine PDF-Datei als Unterlage ergibt 503 vor jeder Änderung (R121), danach mit gültigen Dateien 200', async (t) => {
modusMerken(t)
termineMit('on', [])
const fs = await import('node:fs')
const os = await import('node:os')
const path = await import('node:path')
const { legalPdfFiles } = await import('../server/services/legalDocs.js')
const echt = legalPdfFiles('termine', config.termine.avvVersion)
const kopien = fs.mkdtempSync(path.join(os.tmpdir(), 'f13-pdf-'))
fs.mkdirSync(path.join(kopien, 'rechtliches'))
const kopiere = () => {
for (const f of Object.values(echt)) fs.copyFileSync(f.path, path.join(kopien, 'rechtliches', path.basename(f.path)))
}
t.after(() => {
setLegalDirForTests(null)
fs.rmSync(kopien, { recursive: true, force: true })
})
setLegalDirForTests(kopien)
const k = legeKundeF13()
const mailsVorher = mails.sent.length
for (const [doc, inhalt] of [['lb', ''], ['avv', '<html>kein PDF</html>'], ['lb', '%PDF']]) {
kopiere()
fs.writeFileSync(path.join(kopien, 'rechtliches', path.basename(echt[doc].path)), inhalt)
const res = await aktivierenF13(k, feldF13(k))
assert.equal(res.status, 503, `${doc}: ${JSON.stringify(inhalt)}`)
assert.match(res.body.error, /Rechtsunterlagen/)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null, 'nichts angelegt')
assert.equal(consentsDesF13(k.projectId).length, 0, 'kein Nachweis')
assert.equal(mails.sent.length, mailsVorher)
}
kopiere()
const ok = await aktivierenF13(k, feldF13(k))
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.equal(consentsDesF13(k.projectId).length, 1)
assert.equal(k16AnF13(k.email).length, 1)
})
test('get_admin_termine_feld: GET /api/team/funktionen/:kunde liefert termine { textformNoetig, avvVersion, links } je Modus', async (t) => {
modusMerken(t)
const k = legeKundeF13({ email: 'get-f13@example.invalid' })
const v = config.termine.avvVersion
const feldVon = async () => (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).termine
termineMit('off')
assert.equal((await feldVon()).textformNoetig, false)
termineMit('pilot', ['*'])
assert.equal((await feldVon()).textformNoetig, false)
termineMit('on', ['get-f13@example.invalid'])
assert.equal((await feldVon()).textformNoetig, false, 'Pilotadresse')
termineMit('on', [])
const f = await feldVon()
assert.deepEqual(Object.keys(f).sort(), ['avvVersion', 'links', 'textformNoetig'])
assert.equal(f.textformNoetig, true)
assert.equal(f.avvVersion, v)
assert.deepEqual(Object.keys(f.links).sort(), ['avv', 'lb'])
// bisherige Antwort bleibt: projects
const ganz = await get(`/api/team/funktionen/${k.customerId}`, STAFF)
assert.ok(Array.isArray(ganz.projects))
// unbekannter Kunde: Modus on => Textform nötig (ohne Adresse), keine Ausnahme
termineMit('on', ['*'])
assert.equal((await get('/api/team/funktionen/c_gibtsnicht', STAFF)).termine.textformNoetig, true)
})
test('chat_vorlage_versioniert: die Adressen für die Chat-Vorlage verweisen nur auf Seiten mit Fassung (-<Fassung>.html), absolut, folgen der aktuellen Fassung', async (t) => {
modusMerken(t)
termineMit('on', [])
const k = legeKundeF13()
const v = config.termine.avvVersion
const { links } = (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).termine
assert.equal(links.lb, `${config.publicBaseUrl}/rechtliches/leistungsbeschreibung-terminplaner-${v}.html`)
assert.equal(links.avv, `${config.publicBaseUrl}/rechtliches/avv-terminplaner-${v}.html`)
for (const adresse of Object.values(links)) {
assert.ok(adresse.endsWith(`-${v}.html`), adresse)
assert.match(adresse, /^https:\/\//)
}
// eine neue Fassung ändert die Adressen mit
const vorher = config.termine.avvVersion
t.after(() => { config.termine.avvVersion = vorher })
config.termine.avvVersion = '2099-01-02'
const neu = (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).termine
assert.equal(neu.avvVersion, '2099-01-02')
for (const adresse of Object.values(neu.links)) assert.ok(adresse.endsWith('-2099-01-02.html'), adresse)
})
test('zustimmungFuerSitzung(""): der Nachweis der Staff-Aktivierung (leere Sitzung) wird über keine Sitzung gefunden', async (t) => {
modusMerken(t)
termineMit('on', [])
const k = legeKundeF13()
assert.equal((await aktivierenF13(k, feldF13(k))).status, 200)
assert.equal(consentsDesF13(k.projectId).length, 1)
assert.equal(consentsDesF13(k.projectId)[0].stripeSessionId, '')
for (const leer of ['', ' ', undefined, null]) assert.equal(await zustimmungFuerSitzung(leer), null, JSON.stringify(leer))
})
+81 -3
View File
@@ -5,16 +5,18 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js und der relative ./vertraege-link.js werden auf die Dateien umgeleitet)
// public/funktionen.js wirklich laden (die absoluten Importe /fn-util.js und /analyse-view.js und der relative ./vertraege-link.js werden
// auf die Dateien umgeleitet); analyse.js importiert relativ und wird direkt geladen
const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-'))
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
.replace("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`)
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
.replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`)
const file = join(dir, 'funktionen.mjs')
writeFileSync(file, src)
const { initFunktionen, initNews, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href)
const { initFunktionen, initNews } = await import(pathToFileURL(file).href)
const { initAnalyse, renderAnalyseOff } = await import(fileUrl('analyse.js'))
const news = { state: 'off' }
const project = (id, analyse) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news }, analyse })
@@ -486,3 +488,79 @@ test('Ausschalten: Hinweis vorher, nach "Ja, ausschalten" Bestätigung, erst "OK
ok.handlers.click()
assert.equal(reloads, 1)
})
// ---- Webshop-Kachel (Task G1): nur bei shop.verfuegbar, Pille und Knopf nach dem Zustand des Funktionsdokuments
const shopProject = (id, state, extra = {}) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news, shop: { state, ...extra } }, analyse: { comingSoon: true } })
test('Webshop-Kachel: ohne shop.verfuegbar (fehlt, false) gar keine Kachel', async () => {
for (const extra of [{}, { shop: { verfuegbar: false } }, { shop: {} }, { shop: null }]) {
const html = await renderMit([shopProject('a', 'off')], extra)
assert.doesNotMatch(html, /data-fn-tile="shop"/, JSON.stringify(extra))
assert.doesNotMatch(html, /Webshop/, JSON.stringify(extra))
}
})
test('Webshop-Kachel: Text mit Preis, Knopf und Pille je Zustand, Ziel #webshop mit Projekt', async () => {
const faelle = [
['off', 'Webshop anfragen', 'Aus', 'off'],
['ordered', 'Zahlung offen', 'Zahlung offen', 'setup'],
['setup', 'In Einrichtung', 'In Einrichtung', 'setup'],
['active', 'Webshop öffnen', 'Aktiv', 'on'],
['disabled', 'Webshop ansehen', 'Abgeschaltet', 'off'],
]
for (const [state, knopf, pille, klasse] of faelle) {
const html = await renderMit([shopProject('p1', state)], { shop: { verfuegbar: true } })
const t = tile(html, 'shop')
assert.match(t, /<h3>Webshop<\/h3>/, state)
assert.match(t, /Dein eigener Online-Shop auf deiner Website\. Zahlungen gehen direkt auf dein Stripe-Konto\. 299 € einmalig\./, state)
assert.match(t, new RegExp(`<button type="button" class="kb-btn-primary fn-tile-cta" data-view="webshop" data-project="p1">${knopf}</button>`), state)
assert.match(t, new RegExp(`fn-pill fn-pill-${klasse}">${pille}</span>`), state)
}
// ohne Dokument (features.shop fehlt) gilt off
const ohne = await renderMit([{ projectId: 'p1', projectName: 'P', features: { news }, analyse: { comingSoon: true } }], { shop: { verfuegbar: true } })
assert.match(tile(ohne, 'shop'), /Webshop anfragen/)
assert.match(tile(ohne, 'shop'), /fn-pill-off">Aus</)
})
test('Webshop-Kachel: Preis aus dem Register maskiert, Rückfall 299 €, Kachel steht nach dem Terminplaner und vor der Analyse', async () => {
const html = await renderMit([shopProject('p1', 'off')], { shop: { verfuegbar: true }, features: { shop: { priceLabel: '<img src=x onerror=1> 349 €' } } })
assert.doesNotMatch(html, /<img/)
assert.match(tile(html, 'shop'), /&lt;img src=x onerror=1&gt; 349 € einmalig/)
const rueck = await renderMit([shopProject('p1', 'off')], { shop: { verfuegbar: true }, features: { shop: { priceLabel: '' } } })
assert.match(tile(rueck, 'shop'), /299 € einmalig/)
const mitAnalyse = await renderMit([{ ...shopProject('p1', 'off'), analyse: { state: 'off', eligible: true } }], { shop: { verfuegbar: true }, termine: { verfuegbar: true, bestellbar: true } })
const reihenfolge = ['news', 'termine', 'shop', 'analyse'].map((k) => mitAnalyse.indexOf(`data-fn-tile="${k}"`))
assert.ok(reihenfolge.every((i) => i >= 0))
assert.deepEqual([...reihenfolge].sort((a, b) => a - b), reihenfolge)
})
test('Webshop-Kachel: mehrere Websites ⇒ aktives zuerst, dann Einrichtung, dann abgeschaltet; ohne Website ein Hinweis statt Knopf', async () => {
const mehrere = await renderMit([shopProject('a', 'off'), shopProject('b', 'disabled'), shopProject('c', 'setup'), shopProject('d', 'active')], { shop: { verfuegbar: true } })
assert.match(tile(mehrere, 'shop'), /data-project="d">Webshop öffnen/)
assert.match(tile(mehrere, 'shop'), /aktiv für 1 von 4 Websites/)
const ohneAktiv = await renderMit([shopProject('a', 'off'), shopProject('b', 'disabled'), shopProject('c', 'ordered')], { shop: { verfuegbar: true } })
assert.match(tile(ohneAktiv, 'shop'), /data-project="c">Zahlung offen/)
const keine = await renderMit([], { shop: { verfuegbar: true } })
assert.match(tile(keine, 'shop'), /Sobald deine Website angelegt ist/)
assert.doesNotMatch(tile(keine, 'shop'), /<button/)
})
test('Webshop-Kachel: Projekt-ID maskiert, Klick legt das Wunschprojekt in #webshop-body', async () => {
const bose = await renderMit([shopProject('"><img src=x>', 'off')], { shop: { verfuegbar: true } })
assert.doesNotMatch(bose, /<img/)
// Klick-Handler: derselbe Aufbau wie bei der Terminplaner-Kachel
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects: [shopProject('p7', 'active')], shop: { verfuegbar: true } }) })
const handlers = {}
const knopf = { dataset: { project: 'p7' }, addEventListener: (typ, fn) => { handlers[typ] = fn } }
const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-fn-tile="shop"] .fn-tile-cta[data-project]' ? knopf : null) }
const body = { dataset: {} }
const vorher = globalThis.document
globalThis.document = { getElementById: (id) => (id === 'webshop-body' ? body : null) }
try {
await initFunktionen(root)
handlers.click({ currentTarget: knopf })
} finally {
globalThis.document = vorher
}
assert.equal(body.dataset.wunschProjekt, 'p7')
})
+5 -1
View File
@@ -65,7 +65,11 @@ test('importNewRepos legt nur fehlende an', async () => {
const neu = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/neu')
assert.deepEqual([neu.projectName, neu.status, neu.subdomain || '', neu.previewUrl || ''], ['Döner Nation', 'synced', '', ''])
assert.ok(aw.all('websiteProjects').some((d) => d.repoFullName === 'knso/privat'))
assert.ok(!aw.all('websiteProjects').some((d) => d.repoFullName === 'JUSN/archiv'))
// In Gitea archivierte Repos kommen als archiviertes Projekt an (sichtbar unter "Archiviert"),
// aber nicht in createdRepos (keine Zuordnungs-Mail)
const archiv = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/archiv')
assert.deepEqual([archiv.archived, archiv.status], [true, 'archived'])
assert.equal(r.archivedImported, 1)
assert.equal(aw.calls.filter((c) => c.method === 'PATCH').length, 0)
})
test('importNewRepos läuft nie doppelt', async () => {
+8
View File
@@ -223,6 +223,14 @@ test('Webhook des System-Kontos', async () => {
await webhook(pushPayload({ full_name: 'WEBklar/D-ner-Nation', sha: 'cafe0001', pusher: 'webklar-system' }))
assert.equal(aw.all('worksheets').length, before)
})
test('System-Commit an Projekt mit Kunde ohne Ticket legt kein Auto-Ticket an', async () => {
gitea.addRepo({ full_name: 'WEBklar/neu-mit-kunde' })
aw.insert('websiteProjects', { $id: 'p_sys', repoFullName: 'WEBklar/neu-mit-kunde', projectName: 'Neu', customerId: 'c1', ticketId: '' })
const before = aw.all('workorders').length
await webhook(pushPayload({ full_name: 'WEBklar/neu-mit-kunde', sha: 'cafe0002', pusher: 'webklar-system' }))
assert.equal(aw.all('workorders').length, before)
assert.equal(aw.get('websiteProjects', 'p_sys').ticketId, '')
})
test('Webhook wartet auf laufenden Umzug', async () => {
gitea.setTransferDelay(300)
const move = post('/api/admin/website-projects/p5/assign', { customerId: 'c1', ticketId: 't1' }, 'jwt-admin') // p5: JUSN/langsam
+41 -9
View File
@@ -28,6 +28,10 @@ import { randomUUID } from 'node:crypto'
export async function startFakeAppwrite(options = {}) {
const calls = []
const pendingFailures = []
// Optionale Unique-Indizes wie in Appwrite/MariaDB: { websiteProjects: ['repoFullName', 'subdomain'] }.
// Strings vergleichen ohne Gross-/Kleinschreibung, null/undefined kollidieren nie, '' schon.
const uniqueAttrs = options.unique || {}
let beforeCreate = null
/** @type {Map<string, Map<string, object>>} Sammlungsname -> (Dokument-ID -> Dokument). */
const store = new Map()
@@ -114,11 +118,24 @@ export async function startFakeAppwrite(options = {}) {
send(res, 200, { total, documents: result })
}
function handleCreateDocument(match, searchParams, body, headers, res) {
function uniqueConflict(col, data, ownId) {
for (const attr of uniqueAttrs[col] || []) {
const value = data?.[attr]
if (value === null || value === undefined) continue
const norm = (v) => (typeof v === 'string' ? v.toLowerCase() : v)
for (const doc of collectionOf(col).values()) {
if (doc.$id !== ownId && doc[attr] !== null && doc[attr] !== undefined && norm(doc[attr]) === norm(value)) return true
}
}
return false
}
async function handleCreateDocument(match, searchParams, body, headers, res) {
if (beforeCreate) await beforeCreate(match[2], body?.data || {})
const map = collectionOf(match[2])
const documentId = body?.documentId || randomUUID()
if (map.has(documentId)) {
return send(res, 409, { message: 'Document with the requested ID already exists.' })
if (map.has(documentId) || uniqueConflict(match[2], body?.data, documentId)) {
return send(res, 409, { message: 'Document with the requested ID already exists.', type: 'document_already_exists' })
}
const now = new Date().toISOString()
const doc = { $id: documentId, $createdAt: now, $updatedAt: now, ...(body?.data || {}) }
@@ -135,6 +152,9 @@ export async function startFakeAppwrite(options = {}) {
function handlePatchDocument(match, searchParams, body, headers, res) {
const doc = collectionOf(match[2]).get(match[3])
if (!doc) return send(res, 404, { message: 'Document not found' })
if (uniqueConflict(match[2], body?.data, doc.$id)) {
return send(res, 409, { message: 'Document with the requested ID already exists.', type: 'document_already_exists' })
}
Object.assign(doc, body?.data || {})
doc.$updatedAt = new Date().toISOString()
send(res, 200, doc)
@@ -212,11 +232,9 @@ export async function startFakeAppwrite(options = {}) {
const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname))
if (!route) return send(res, 404, { message: 'Not Found' })
const match = route.re.exec(url.pathname)
try {
route.handler(match, url.searchParams, parsedBody, req.headers, res)
} catch (err) {
send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' })
}
Promise.resolve()
.then(() => route.handler(match, url.searchParams, parsedBody, req.headers, res))
.catch((err) => send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' }))
})
})
@@ -245,10 +263,24 @@ export async function startFakeAppwrite(options = {}) {
pendingFailures.push({ method, pathIncludes, status })
}
/** fn(collection, data) laeuft vor jeder Anlage (z. B. um einen gleichzeitigen Schreiber nachzustellen). */
function setBeforeCreate(fn) {
beforeCreate = fn || null
}
/** Dokument direkt in den Speicher legen (ohne HTTP), z. B. aus einem setBeforeCreate-Haken. */
function insert(col, doc) {
const now = new Date().toISOString()
const id = doc.$id || randomUUID()
const full = { $createdAt: now, $updatedAt: now, ...doc, $id: id }
collectionOf(col).set(id, full)
return full
}
function close() {
for (const socket of sockets) socket.destroy()
return new Promise((resolve) => server.close(() => resolve()))
}
return { endpoint, all, get, calls, failNext, close }
return { endpoint, all, get, calls, failNext, setBeforeCreate, insert, close }
}
+32
View File
@@ -63,6 +63,7 @@ export async function startFakeGitea(options = {}) {
let transferDelayMs = 0
let responseDelayMs = 0
let transferNoopNext = false
let onGenerate = null
const pendingFailures = []
let nextRepoId = 1
@@ -229,6 +230,30 @@ export async function startFakeGitea(options = {}) {
send(res, 200, serializeRepo(found.record))
}
/**
* Repo aus Vorlage erzeugen wie Gitea (`POST /repos/{tplOwner}/{tpl}/generate`): 409, wenn es das
* Ziel schon gibt. `setOnGenerate(fn)` ruft fn(repo) nach dem Anlegen auf, BEVOR geantwortet
* wird - so laesst sich der sofort feuernde Webhook `repository/created` nachstellen.
*/
async function handleGenerate(match, searchParams, body, headers, res) {
const owner = body?.owner || ''
const name = body?.name || ''
if (!owner || !name) return send(res, 422, { message: 'owner und name fehlen' })
if (repos.has(keyFrom(owner, name))) {
return send(res, 409, { message: 'The repository with the same name already exists.' })
}
const record = addRepoInternal({ full_name: `${owner}/${name}`, description: body.description || '', private: body.private })
if (onGenerate) await onGenerate(serializeRepo(record))
send(res, 201, serializeRepo(record))
}
function handleGetBranches(match, searchParams, body, headers, res) {
const found = findRepoOrRedirect(match[1], match[2])
if (!found) return send(res, 404, { message: 'Not Found' })
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/branches')
send(res, 200, [{ name: found.record.default_branch || 'main' }])
}
function handlePatchRepo(match, searchParams, body, headers, res) {
const found = findRepoOrRedirect(match[1], match[2])
if (!found) return send(res, 404, { message: 'Not Found' })
@@ -419,6 +444,8 @@ export async function startFakeGitea(options = {}) {
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks$/, handler: handleGetRepoHooks },
{ method: 'DELETE', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks\/([^/]+)$/, handler: handleDeleteRepoHook },
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/activities\/feeds$/, handler: handleFeeds },
{ method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/generate$/, handler: handleGenerate },
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/branches$/, handler: handleGetBranches },
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleGetContents },
{ method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
{ method: 'PUT', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
@@ -542,6 +569,10 @@ export async function startFakeGitea(options = {}) {
pendingFailures.push({ method, pathIncludes, status })
}
function setOnGenerate(fn) {
onGenerate = fn || null
}
function close() {
for (const socket of sockets) socket.destroy()
return new Promise((resolve) => server.close(() => resolve()))
@@ -549,6 +580,7 @@ export async function startFakeGitea(options = {}) {
return {
baseUrl,
setOnGenerate,
calls,
repo,
addRepo,
+32 -2
View File
@@ -39,6 +39,7 @@ const MOUNTS = [
['/api/abo', (await import('../server/routes/abo.js')).default],
['/api/funktionen', (await import('../server/routes/funktionen.js')).default],
['/api/termine', termineRouter],
['/api/shop', (await import('../server/routes/shop.js')).default],
['/api/zeiten', zeitenRouter],
]
// Oeffentliche Schnittstelle (ohne Session) wie in server/index.js hinter dem Waechter; nicht in MOUNTS, weil sie keine Kunden-Route ist
@@ -145,7 +146,7 @@ test('Behalten der Öffnungszeiten ist in der Kundenansicht gesperrt (Route gefu
assert.equal(res.body.code, 'view_blocked')
})
test('Erlaubnisliste: genau News, Terminplaner, Öffnungszeiten, Analyse-Link, Ende, Logout und Login', () => {
test('Erlaubnisliste: genau News, Terminplaner, Webshop-Verwaltung, Öffnungszeiten, Analyse-Link, Ende, Logout und Login', () => {
const ok = [
['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'],
['POST', '/api/funktionen/analyse/heatmap-link'], ['POST', '/api/auth/kundenansicht/ende'],
@@ -153,10 +154,12 @@ test('Erlaubnisliste: genau News, Terminplaner, Öffnungszeiten, Analyse-Link, E
['POST', '/api/termine/buchungen'], ['POST', '/api/termine/buchungen/abc/aktion'], ['POST', '/api/termine/ressourcen/abc/feed'],
['PATCH', '/api/termine/einstellungen'], ['PATCH', '/api/termine/einstellungen/'],
['DELETE', '/api/termine/buchungen/abc'], ['DELETE', '/api/termine/buchungen/abc/'],
['POST', '/api/shop/produkte'], ['POST', '/api/shop/produkte/'], ['POST', '/api/shop/produkte/sortierung'], ['PUT', '/api/shop/produkte/abc'],
['DELETE', '/api/shop/produkte/abc'], ['PUT', '/api/shop/einstellungen'], ['PUT', '/api/shop/rechtstexte'], ['POST', '/api/shop/bild'],
['PUT', '/api/zeiten/abc'], ['PUT', '/api/zeiten/abc/'],
]
for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`)
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6 + 1, 'genau sechs Termine-Eintraege und ein Zeiten-Eintrag dazu, keine Wildcard')
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6 + 7 + 1, 'genau sechs Termine-, sieben Webshop- und ein Zeiten-Eintrag dazu, keine Wildcard')
for (const [m, p] of [
['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout'],
// Terminplaner: nur der eigene Pfad, nur diese Methoden und Formen
@@ -168,6 +171,14 @@ test('Erlaubnisliste: genau News, Terminplaner, Öffnungszeiten, Analyse-Link, E
['PUT', '/api/termine/einstellungen'], ['PATCH', '/api/termine/buchungen/abc'], ['PATCH', '/api/termine/einstellungen/x'],
['DELETE', '/api/termine/einstellungen'], ['DELETE', '/api/termine/buchungen'], ['DELETE', '/api/termine/buchungen/abc/def'],
['DELETE', '/api/termine/ressourcen/abc'], ['PUT', '/api/termine/buchungen/abc'],
// Webshop: Anfrage stellen und zurueckziehen sind in der Kundenansicht gesperrt (nicht auf der Liste)
['POST', '/api/shop/anfrage'], ['POST', '/api/shop/anfrage/zurueckziehen'],
// Webshop: Oeffnen und Bestaetigen der Rechtstexte macht der Haendler selbst; Stripe und Erstattungen sind nie dabei
['POST', '/api/shop/oeffnen'], ['POST', '/api/shop/rechtstexte/bestaetigen'], ['POST', '/api/shop/stripe/verbinden'], ['POST', '/api/shop/bestellungen/abc/erstatten'],
// und nur die ausgeschriebenen Pfade und Methoden der Verwaltung
['POST', '/api/shop/rechtstexte'], ['POST', '/api/shop/einstellungen'], ['PATCH', '/api/shop/produkte/abc'], ['POST', '/api/shop/produkte/abc'],
['PUT', '/api/shop/produkte'], ['DELETE', '/api/shop/produkte'], ['PUT', '/api/shop/bild'], ['POST', '/api/shop/bild/x'],
['POST', '/api/shopX/produkte'], ['PUT', '/api/shop/produkte/abc/def'], ['DELETE', '/api/shop/einstellungen'],
// Oeffnungszeiten: nur PUT auf das Projekt, sonst nichts
['PUT', '/api/zeiten'], ['PUT', '/api/zeiten/'], ['PUT', '/api/zeiten/abc/vorschau'], ['PUT', '/api/zeiten/abc/def/ghi'], ['PUT', '/api/zeitenX/abc'],
['POST', '/api/zeiten/abc'], ['PATCH', '/api/zeiten/abc'], ['DELETE', '/api/zeiten/abc'], ['POST', '/api/zeiten'],
@@ -203,6 +214,25 @@ test('unbekanntes POST/PUT unter /api/termine in der Kundenansicht -> 403 view_b
}
})
// Router-Abgleich Webshop: genau die sieben Verwaltungs-Schreib-Routen stehen auf der Liste, jeder Eintrag trifft genau eine echte Route
test('Router-Abgleich: genau sieben Schreib-Routen des Webshops stehen auf der Erlaubnisliste', () => {
const shopRouter = MOUNTS.find(([prefix]) => prefix === '/api/shop')[1]
const routen = writeRoutes([['/api/shop', shopRouter]])
assert.deepEqual(
routen.filter(({ method, path: p }) => allowed(method, p)).map(({ method, path: p }) => `${method} ${p}`).sort(),
['DELETE /api/shop/produkte/x', 'POST /api/shop/bild', 'POST /api/shop/produkte', 'POST /api/shop/produkte/sortierung',
'PUT /api/shop/einstellungen', 'PUT /api/shop/produkte/x', 'PUT /api/shop/rechtstexte']
)
const shopEintraege = KUNDENANSICHT_ALLOWED.filter(([, re]) => re.source.includes('shop'))
assert.equal(shopEintraege.length, 7)
for (const [m, re] of shopEintraege) assert.equal(routen.filter((r) => r.method === m && re.test(r.path)).length, 1, `${m} ${re}`)
// gesperrt bleiben insbesondere Anfrage, Oeffnen und Bestaetigen
for (const p of ['/api/shop/anfrage', '/api/shop/anfrage/zurueckziehen', '/api/shop/oeffnen', '/api/shop/rechtstexte/bestaetigen']) {
assert.ok(routen.some((r) => r.method === 'POST' && r.path === p), `Route ${p} existiert`)
assert.ok(!allowed('POST', p), p)
}
})
// Router-Abgleich: die Erlaubnisliste und die echten Schreib-Routen des Terminplaners decken sich genau; eine neue Route faellt
// im Test "jede Schreib-Route ausserhalb der Erlaubnisliste -> 403" auf (sie muss gesperrt sein), ein toter Eintrag hier
test('Router-Abgleich: genau sechs Schreib-Routen des Terminplaners stehen auf der Erlaubnisliste', () => {
+99 -1
View File
@@ -1,6 +1,6 @@
import { test, after, mock } from 'node:test'
import assert from 'node:assert/strict'
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs'
import { closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs'
import fsp from 'node:fs/promises'
import { syncBuiltinESMExports } from 'node:module'
import { tmpdir } from 'node:os'
@@ -200,6 +200,39 @@ test('featureLegalReady("termine"): je PDF stat und "%PDF-", nie den ganzen Inha
assert.equal(await featureLegalReady('termine'), true, 'mit den ausgelieferten Dateien bereit')
})
// R121: ein Anhang ist ein PDF. Eine leere oder keine PDF-Datei wird nie angehängt, sondern wirft (die Aufrufer fangen den Wurf).
test('legalPdfAttachments: eine leere Datei oder eine Datei ohne "%PDF-" am Anfang wirft, ausgenommene Dokumente zählen nicht', async () => {
const kopien = mkdtempSync(join(tmpdir(), 'legal-anhang-'))
mkdirSync(join(kopien, 'rechtliches'))
const echtePdf = Object.fromEntries(Object.entries(legalPdfFiles('termine', V)).map(([doc, f]) => [doc, f.path]))
const pdfPfad = (doc) => join(kopien, 'rechtliches', echtePdf[doc].split('/').at(-1))
const bereit = () => {
for (const pfad of Object.values(echtePdf)) copyFileSync(pfad, join(kopien, 'rechtliches', pfad.split('/').at(-1)))
}
try {
setLegalDirForTests(kopien)
bereit()
const gut = await legalPdfAttachments('termine', V)
assert.equal(gut.length, 3)
for (const a of gut) assert.equal(a.content.subarray(0, 5).toString('latin1'), '%PDF-')
for (const doc of ['lb', 'avv', 'widerruf']) {
for (const [mangel, inhaltDatei] of [['leer', ''], ['kein PDF', '<html>kein PDF</html>'], ['zu kurz', '%PDF'], ['PDF nicht am Anfang', ' %PDF-1.4\n']]) {
writeFileSync(pdfPfad(doc), inhaltDatei)
await assert.rejects(legalPdfAttachments('termine', V), /PDF/, `${doc}: ${mangel}`)
await assert.rejects(featureLegalAttachments('termine'), /PDF/, `${doc}: ${mangel} (Funktion)`)
// ein ausgenommenes Dokument wird nicht gelesen und darf kaputt sein
const ohne = await legalPdfAttachments('termine', V, { ohne: [doc] })
assert.equal(ohne.length, 2, `${doc}: ${mangel}, ausgenommen`)
}
bereit()
assert.equal((await legalPdfAttachments('termine', V)).length, 3, `${doc}: wieder in Ordnung`)
}
} finally {
setLegalDirForTests(null)
rmSync(kopien, { recursive: true, force: true })
}
})
// Fassung wählbar (Task F8, Fix-Runde 1): K15 schickt die Unterlagen der Fassung, der der Kunde zugestimmt hat
test('featureLegalAttachments / featureLegalReady / featureLegalPaths mit opts.fassung', async () => {
assert.deepEqual(featureLegalPaths('termine'), legalPaths('termine', V))
@@ -258,6 +291,71 @@ test('News: Leistungsbeschreibung folgt der Fassung <F> (Adresse unverändert, k
assert.equal(Object.getOwnPropertyDescriptor(n, 'leistungsbeschreibung').get, undefined)
})
// Webshop (Block A3): eigener Satz mit eigener Fassung config.shop.fassung; die Dateien hat Block J1 angelegt
test('Webshop-Satz: Adressen, PDF-Namen und Funktionszuordnung folgen config.shop.fassung', async () => {
const S = config.shop.fassung
assert.match(S, /^\d{4}-\d{2}-\d{2}$/)
assert.deepEqual(legalPaths('shop', S), {
lb: `/rechtliches/leistungsbeschreibung-webshop-${S}.html`,
avv: `/rechtliches/avv-webshop-${S}.html`,
widerruf: `/rechtliches/widerrufsbelehrung-funktionen-${S}.html`,
})
assert.deepEqual(Object.keys(legalPaths('shop', S)), ['lb', 'avv', 'widerruf'])
const f = legalPdfFiles('shop', S)
assert.equal(f.lb.filename, `Leistungsbeschreibung-Webshop-${S}.pdf`)
assert.equal(f.avv.filename, `AVV-Webshop-${S}.pdf`)
assert.equal(f.widerruf.filename, `Widerrufsbelehrung-Muster-Widerrufsformular-${S}.pdf`)
assert.equal(f.avv.path, new URL(`../public/rechtliches/avv-webshop-${S}.pdf`, import.meta.url).pathname)
assert.throws(() => legalPaths('shop', '2026-10-5'), /Fassung/)
// die aktuelle Fassung des Satzes shop ist config.shop.fassung (nicht die der Termine)
assert.deepEqual(featureLegalPaths('shop'), legalPaths('shop', S))
const alt = config.shop.fassung
config.shop.fassung = '2099-01-02'
try {
assert.deepEqual(featureLegalPaths('shop'), legalPaths('shop', '2099-01-02'))
assert.deepEqual(featureLegalPaths('termine'), legalPaths('termine', V), 'Termine bleiben bei ihrer Fassung')
await assert.rejects(featureLegalAttachments('shop'))
} finally {
config.shop.fassung = alt
}
assert.deepEqual(featureLegalPaths('shop', { fassung: '2026-10-01' }), legalPaths('shop', '2026-10-01'))
assert.deepEqual(featureLegalPaths('termine'), legalPaths('termine', V))
})
test('featureLegalReady("shop") ist true: alle sechs Dateien der Fassung (Leistungsbeschreibung, AVV, Widerrufsbelehrung als HTML und PDF) sind da (Block J1)', async () => {
const S = config.shop.fassung
const html = Object.values(legalPaths('shop', S))
const pdfs = Object.values(legalPdfFiles('shop', S)).map((x) => x.path)
assert.equal(html.length, 3)
assert.equal(pdfs.length, 3)
for (const p of html) assert.ok(existsSync(root(`public${p}`)), `${p} fehlt`)
for (const p of pdfs) assert.ok(existsSync(p), `${p} fehlt`)
assert.equal(await featureLegalReady('shop'), true)
assert.equal(await featureLegalReady('shop', { fassung: '1999-01-01' }), false)
// die Anhaenge der Bestellmail: Leistungsbeschreibung, AVV, Widerrufsbelehrung als echte PDFs
const anhaenge = await featureLegalAttachments('shop')
assert.deepEqual(anhaenge.map((a) => a.filename), [
`Leistungsbeschreibung-Webshop-${S}.pdf`,
`AVV-Webshop-${S}.pdf`,
`Widerrufsbelehrung-Muster-Widerrufsformular-${S}.pdf`,
])
for (const a of anhaenge) {
assert.equal(a.contentType, 'application/pdf')
assert.equal(a.content.subarray(0, 5).toString('latin1'), '%PDF-')
}
})
test('Register: leistungsbeschreibung, avv und widerruf des Webshops folgen config.shop.fassung (Drift-Wächter)', () => {
const s = featureConfig('shop')
const S = config.shop.fassung
const p = legalPaths('shop', S)
assert.deepEqual(s.leistungsbeschreibung, { version: S, url: p.lb })
assert.deepEqual(s.avv, { version: S, url: p.avv })
assert.deepEqual(s.widerruf, { version: S, url: p.widerruf })
assert.equal(s.consent.version, S)
})
test('config.js importiert legalDocs.js nicht (Adresse aus Slug und Version gebaut)', () => {
const src = readFileSync(root('server/config.js'), 'utf8')
assert.doesNotMatch(src, /^\s*(import\b[^\n]*|.*\bimport\()['"`][^'"`]*legalDocs/m)
+84 -3
View File
@@ -2,6 +2,8 @@ import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const { config } = await import('../server/config.js')
@@ -446,6 +448,39 @@ test('Fassung 4 archiviert: HTML bis auf den PDF-Link bytegleich, PDF bytegleich
assert.ok(archived.includes('Version 4 · Stand 30.09.2026'))
})
// = `git show 3a0b008:public/vertragsunterlagen/funktionen-leistungsbeschreibung.html | sha256sum` (News-Leistungsbeschreibung, Stand 30.09.2026).
// Bestellungen seit dem 30.09.2026 haben diesem Text zugestimmt; die geltende Datei (Stand 05.10.2026) ersetzt ihn nur für künftige Bestellungen.
const NEWS_LB = 'public/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
const NEWS_LB_ARCHIV = 'public/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html'
const NEWS_LB_ARCHIV_SHA256 = '88a34b43362f0e403c5d0d64c2503d34de47860cacdab498f3cd72cee6df5bbc'
test('News-Leistungsbeschreibung vom 30.09.2026 unverändert archiviert; die geltende Datei verweist darauf', () => {
assert.equal(sha256(NEWS_LB_ARCHIV), NEWS_LB_ARCHIV_SHA256, 'das Archiv darf nie verändert werden (Bestellungen seit 2026-09-30 beruhen darauf)')
const alt = read(NEWS_LB_ARCHIV)
assert.ok(alt.includes('<p class="meta">Stand 30.09.2026</p>'))
assert.ok(alt.includes('§ 356 Abs. 4 BGB') && alt.includes('30,00 € einmalig, inklusive Umsatzsteuer'), 'der alte Wortlaut, nicht der berichtigte')
assert.ok(!alt.includes('Frühere Fassung'), 'das Archiv trägt keinen Verweis (byte-identisch mit dem Stand 3a0b008)')
// die geltende Datei bleibt die berichtigte Fassung und nennt die frühere mit Link auf das Archiv
const neu = read(NEWS_LB)
assert.notEqual(sha256(NEWS_LB), NEWS_LB_ARCHIV_SHA256)
assert.ok(neu.includes('<p class="meta">Stand 05.10.2026</p>'))
assert.ok(neu.includes('<p class="meta">Frühere Fassung vom 30.09.2026: <a href="/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html">Leistungsbeschreibung (Stand 30.09.2026)</a></p>'))
assert.equal(neu.split('funktionen-leistungsbeschreibung-2026-09-30.html').length - 1, 1, 'genau ein Verweis')
// Rest der geltenden Datei unverändert gegenüber der Fassung ohne die neue Zeile: nur diese eine Zeile kam hinzu
const ohneZeile = neu.split('\n').filter((z) => !z.includes('Frühere Fassung vom 30.09.2026')).join('\n')
assert.ok(ohneZeile.includes('Der Einbau kostet 30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG).') && !ohneZeile.includes('§ 356 Abs. 4'))
})
test('News-Leistungsbeschreibung vom 30.09.2026: das Archiv ist bytegleich mit dem Stand 3a0b008 (nur mit Git-Verlauf)', (t) => {
const r = spawnSync('git', ['show', '3a0b008:public/vertragsunterlagen/funktionen-leistungsbeschreibung.html'], {
cwd: fileURLToPath(new URL('..', import.meta.url)),
timeout: 20000,
maxBuffer: 1 << 20,
})
if (r.status !== 0) return t.skip('Git-Verlauf mit 3a0b008 nicht vorhanden')
assert.ok(Buffer.compare(r.stdout, readFileSync(file(NEWS_LB_ARCHIV))) === 0, 'das Archiv weicht vom Stand 3a0b008 ab')
})
test('Fassung 5: Unterstützung am Ende von § 4 Abs. 4, Abs. 7 verweist auf Satz 1, 2 und 4', () => {
const text = strip(read(CUR))
// "Dazu zählen" bleibt direkt hinter Satz 1 und bezieht sich weiter auf die aufgewendete Zeit
@@ -513,11 +548,11 @@ test('legal_datenschutz_kundenansicht: Abschnitt 6 nennt Kundenansicht, Sperren,
// ---------------------------------------------------------------- Abschnitt 6c und Funktionsbestellung (TP2 F12a)
/** Der Block von <h2 id="termine"> bis vor <h2>7. in public/datenschutz.html (roh, mit Tags). */
/** Der Block von <h2 id="termine"> bis vor die nächste Überschrift (6d Webshop) in public/datenschutz.html (roh, mit Tags). */
const block6c = (html = read(DS)) => {
const a = html.indexOf('<h2 id="termine">')
assert.ok(a >= 0, '<h2 id="termine"> fehlt')
const b = html.indexOf('<h2>7. ', a)
const b = html.indexOf('<h2', a + 5)
assert.ok(b > a)
return html.slice(a, b)
}
@@ -544,6 +579,8 @@ test('legal_datenschutz_termine: Abschnitt 6c steht zwischen 6b und 7 und nennt
assert.ok(text.includes('schon beim Laden der Seite') && text.includes('Konfiguration mit den Texten und Leistungen'))
// Ende des Abos (Wortlaut der Vorgabe)
assert.ok(text.includes('Endet das Wartungs-Abo oder schalten wir die Funktion ab, schalten wir den Terminplaner spätestens am folgenden Tag ab und löschen alle Termindaten und Einstellungen 30 Tage danach; beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schalten wir ihn mit den vorhandenen Daten wieder ein.'))
// Ausnahme: das Einrichtungs-Ticket (Kurzfassung der Einstellungen, Arbeitsnachweis) bleibt, ohne Daten von Gästen (AVV Nr. 9 Abs. 6)
assert.ok(text.includes('wieder ein. Eine Ausnahme ist das Einrichtungs-Ticket, in dem wir die Einrichtung als Arbeitsnachweis festhalten (Abschnitt 6): Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen. In unseren Datensicherungen'))
assert.ok(text.includes('bleiben gelöschte Daten noch bis zu sieben Tage erhalten'))
// die Verbote der Nachbartests gelten auch hier
for (const verboten of ['Abschnitt 6 und 7', 'spätestens nach 24 Stunden', 'Endet die Zusammenarbeit, schalten wir', 'Unterauftragsverarbeiter', 'im Rahmen des Wartungs-Abos']) assert.ok(!text.includes(verboten), `noch da: ${verboten}`)
@@ -556,6 +593,35 @@ test('legal_datenschutz_termine: Abschnitt 6c steht zwischen 6b und 7 und nennt
])
})
test('legal_datenschutz_webshop: Abschnitt 6d steht zwischen 6c und 7 und nennt Rolle, Stripe Connect, Orte und Fristen', () => {
const html = read(DS)
const termine = html.indexOf('id="termine"')
const webshop = html.indexOf('id="webshop"')
const sieben = html.indexOf('<h2>7. ')
assert.ok(termine > 0 && webshop > termine && sieben > webshop, 'Reihenfolge 6c < 6d < 7')
assert.equal(html.split('id="webshop"').length - 1, 1)
assert.ok(html.includes('<h2 id="webshop">6d. Webshop auf Websites unserer Kunden</h2>'))
const roh = html.slice(webshop, sieben)
assert.ok(!roh.includes('<!--'), 'kein Kommentar im Block')
const text = strip(roh)
assert.ok(text.includes('ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden'))
assert.ok(text.includes('Auftragsverarbeitungsvertrag Webshop'))
// Stripe Connect: Weitergabe von E-Mail, Website und Projektname auf Klick, Konto-ID und Stand, Rechtsgrundlage, Hinweise von Stripe
assert.ok(text.includes('Stripe Payments Europe, Limited') && text.includes('Dublin'))
assert.ok(text.includes('seine E-Mail-Adresse, die Adresse seiner Website und den Namen seines Projekts'))
assert.ok(text.includes('Wir speichern die Kennung des Stripe-Kontos und seinen Stand'))
assert.ok(text.includes('Art. 6 Abs. 1 lit. b DSGVO') && text.includes('stripe.com/de/privacy'))
assert.ok(text.includes('wir erhalten und verwahren kein Geld der Käufer'))
// Orte und Fristen wie im AVV Webshop
for (const s of ['Falkenstein', 'Nürnberg', 'nach 30 Tagen', 'drei Jahre nach dem Ende des Jahres der Bestellung', '90 Tage', 'bis zu sieben Tage', 'Sitzungsspeicher', 'nicht dauerhaft']) assert.ok(text.includes(s), `6d: ${s}`)
assert.ok(text.includes('Eine Ausnahme ist das Einrichtungs-Ticket'))
// kein Abo-Bezug: der Webshop endet mit dem Vertrag über den Betrieb der Website (Entscheidung W5)
assert.ok(text.includes('endet der Vertrag über den Betrieb seiner Website'))
assert.ok(!text.includes('Wartungs-Abo') && !text.includes('Hosting-Abo'), '6d nennt kein bestimmtes Abo')
// Empfängerliste nennt den Webshop bei Stripe
assert.ok(strip(html).includes('Eröffnung des Stripe-Kontos für den Webshop unserer Kunden (Abschnitte 6d und 7)'))
})
test('datenschutz_6c_gleich_markdown: der Block in public/datenschutz.html ist die Markdown-Quelle, die Quelle ist ohne Entwurfs- und Prüflisten-Reste', () => {
const md = read('docs/rechtliches/termine-datenschutz-6c.md').replace(/\r/g, '')
const fence = /```html\n([\s\S]*?)\n```/.exec(md)
@@ -573,6 +639,7 @@ test('legal_datenschutz_funktionsbestellung: Abschnitt 7 nennt den Nachweis der
const absatz = 'Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner), speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“, die Stripe-Sitzung, IP-Adresse und Browserkennung sowie Ihr Konto und die angemeldete Person. '
+ 'Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO; unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und der Zustimmungen, insbesondere für den Widerruf. '
+ 'Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet. '
+ 'Bestellen Sie ohne Abo (zum Beispiel nur die News-Pinnwand), löschen wir diese Angaben spätestens drei Jahre nach Ablauf des Jahres der Bestellung. '
+ 'Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.'
assert.ok(abos.includes(absatz), 'Absatz zur Funktionsbestellung (wörtlich) fehlt')
assert.equal(abos.split('Bestellen Sie eine Funktion').length - 1, 1)
@@ -604,11 +671,25 @@ test('wiederkehr_satz_gleich_lb: Rückkehr des Abos innerhalb von 30 Tagen steht
}
})
test('pdf_pruefsumme: neben dem PDF der Datenschutzerklärung liegt .sha256 mit der SHA-256 von public/datenschutz.html (HTML geändert ohne neues PDF fällt auf)', () => {
const summe = read('docs/WEBklar-Datenschutzerklaerung.pdf.sha256')
assert.match(summe, /^[0-9a-f]{64}\n$/, 'nur die Prüfsumme und ein Zeilenende')
assert.equal(summe.trim(), sha256(DS), 'public/datenschutz.html wurde geändert, ohne das PDF neu zu drucken (scripts/build-legal-pdfs.sh datenschutz)')
// eine Änderung am HTML ergäbe eine andere Prüfsumme
assert.notEqual(createHash('sha256').update(`${read(DS)} `).digest('hex'), summe.trim())
// das Skript schreibt die Prüfsumme und druckt bei passender Prüfsumme nicht neu (FORCE=1 druckt neu)
const sh = read('scripts/build-legal-pdfs.sh')
const modus = sh.slice(sh.indexOf('\ndatenschutz)'), sh.indexOf('\nall)'))
assert.ok(modus.includes('sha256sum "$quelle"') && modus.includes('echo "$summe" > "$ziel.sha256"'))
assert.ok(modus.includes('${FORCE:-}') && modus.includes('(unveraendert)'))
assert.ok(modus.includes('pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf'))
})
test('datenschutz_pdf: das PDF der Datenschutzerklärung ist eingecheckt (> 20 KB, %PDF) und das Skript baut es im Modus datenschutz', () => {
const pdf = readFileSync(file('docs/WEBklar-Datenschutzerklaerung.pdf'))
assert.ok(pdf.length > 20 * 1024, `PDF zu klein: ${pdf.length} Bytes`)
assert.equal(pdf.subarray(0, 5).toString('latin1'), '%PDF-')
assert.ok(pdf.subarray(-2048).toString('latin1').includes('%%EOF'), 'PDF vollständig geschrieben')
const skript = read('scripts/build-legal-pdfs.sh')
assert.match(skript, /datenschutz\)\n\s*pdf public\/datenschutz\.html docs\/WEBklar-Datenschutzerklaerung\.pdf\n\s*;;/)
assert.match(skript, /\ndatenschutz\)[\s\S]*\n\s+pdf public\/datenschutz\.html docs\/WEBklar-Datenschutzerklaerung\.pdf\n[\s\S]*?;;/)
})
+258 -6
View File
@@ -6,11 +6,12 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url)
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
// K24–K26 gehoeren dem Webshop (auf main); dieser Zweig traegt bis zur Zusammenfuehrung nur die Oeffnungszeiten-Mails K27–K30
const EXPECTED = [...ids('G', 8), ...ids('K', 23), 'K27', 'K28', 'K29', 'K30', ...ids('T', 19), 'T20']
const EXPECTED = [...ids('G', 15), ...ids('K', 30), ...ids('T', 21)]
// GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten)
const GUEST = ids('G', 8)
const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15']
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
const allBlocks = (tpl, on) => Object.fromEntries(tpl.blocks.map((b) => [b, on]))
@@ -25,8 +26,8 @@ for (const id of EXPECTED) {
const source = readFileSync(new URL(tpl.file, DIR), 'utf8')
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail), [Finanzen] (Bankzugang) oder [Terminplaner] (Betriebsmeldung)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen] oder [Terminplaner] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail), [Finanzen] (Bankzugang), [Terminplaner] (Betriebsmeldung) oder [Webshop] (neue Anfrage)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner] oder [Webshop] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
@@ -71,6 +72,20 @@ test('T20: Betreff [Terminplaner], Platzhalter vorfall/details/zeit, keine Blöc
for (const line of ['Mailkontingent erreicht', 'Zeile 1\nZeile <2>', '04.10.2026, 10:00']) assert.ok(mail.text.includes(line), `Text enthält: ${line}\n---\n${mail.text}`)
})
// Webshop B1: Team-Hinweis auf eine neue Anfrage (Versand ueber sendAdminNotice, services/shop/anfrage.js)
test('T21: Betreff [Webshop], Platzhalter der Anfrage, keine Blöcke, Knopf zum Kunden im Ticketsystem', () => {
const tpl = loadTemplate('T21')
assert.equal(tpl.file, 'T21 Neue Webshop-Anfrage.html')
assert.equal(tpl.subject, '[Webshop] Neue Anfrage: {{kunde_name}}')
assert.deepEqual(tpl.placeholders, ['kunde_name', 'email', 'projekt', 'warenart', 'beschreibung', 'artikelzahl', 'lieferung', 'nachricht', 'woms_url'])
assert.deepEqual(tpl.blocks, [])
const mail = renderMail('T21', { ...sampleValues(tpl), beschreibung: 'Zeile 1\nZeile <2>', woms_url: 'https://ticket.webklar.com/customers/c1?tab=funktionen' })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, '[Webshop] Neue Anfrage: Wert-kunde_name')
assert.match(mail.html, /Zeile 1<br>Zeile &lt;2&gt;/)
assert.match(mail.html, /<a href="https:\/\/ticket\.webklar\.com\/customers\/c1\?tab=funktionen"/)
})
// Terminplaner TP2 (B9a): K15 und K16 gelten für jede Funktion des Registers; der Wortlaut je Funktion kommt aus den Platzhaltern
test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text', () => {
const k15 = loadTemplate('K15')
@@ -700,7 +715,7 @@ test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => {
})
// Etappe 4: das Admin-Panel ist abgeschaltet, Team-Mails fuehren ins Ticketsystem
const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20']
const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21']
test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => {
for (const id of EXPECTED) {
@@ -1028,3 +1043,240 @@ test('K20 und K21: WEBklar-Layout in Du-Form, Gastdaten maskiert, Blöcke je Fal
for (const [anderer, satz] of Object.entries(SATZ)) assert.equal(mail.text.includes(satz), anderer === block, `${block}: ${satz}`)
}
})
// Webshop B2: K24 meldet dem Kunden die Entscheidung des Teams über seine Webshop-Anfrage (Block angenommen oder abgelehnt)
const K24_WERTE = { kunde_name: 'Erika', projekt: 'Töpferei', preis: '299 €', ust_hinweis: 'ohne Umsatzsteuer gemäß § 19 UStG', begruendung: 'Wir verkaufen keine <Waffen>.\nBitte melde Dich.', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' }
test('K24: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => {
const tpl = loadTemplate('K24')
assert.equal(tpl.file, 'K24 Webshop-Anfrage.html')
assert.equal(tpl.subject, 'Deine Webshop-Anfrage für {{projekt}}')
assert.deepEqual(tpl.placeholders, ['kunde_name', 'projekt', 'preis', 'ust_hinweis', 'begruendung', 'portal_url'])
assert.deepEqual(tpl.blocks, ['angenommen', 'abgelehnt'])
})
test('K24 angenommen: Preis mit Umsatzsteuerhinweis, Knopf ins Portal, keine Begründung', () => {
const mail = renderMail('K24', K24_WERTE, { angenommen: true, abgelehnt: false })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Deine Webshop-Anfrage für Töpferei')
assert.match(mail.text, /Hallo Erika/)
assert.match(mail.text, /angenommen/)
assert.match(mail.text, /299 €/)
assert.match(mail.text, /§ 19 UStG/)
assert.doesNotMatch(mail.text, /Waffen|Begründung|leider/)
assert.match(mail.html, /href="https:\/\/portal\.example\/dashboard\.html#webshop\?projekt=p1"/)
})
test('K24 abgelehnt: nennt die Begründung (maskiert, Zeilenumbruch), keinen Preis', () => {
const mail = renderMail('K24', K24_WERTE, { angenommen: false, abgelehnt: true })
assert.deepEqual(mail.problems, [])
assert.match(mail.text, /leider/)
assert.match(mail.text, /Wir verkaufen keine <Waffen>\./)
assert.match(mail.html, /Wir verkaufen keine &lt;Waffen&gt;\.<br>Bitte melde Dich\./)
assert.doesNotMatch(mail.text, /299 €|UStG|angenommen/)
})
// ---------------------------------------------------------------------------
// Webshop E4: Käufermails G09–G14 (Sie-Form, Betreff in der Vorlage, Fuß mit Anbieter und Widerrufsfunktion)
// und K25, K26 an den Händler (Du-Form)
// ---------------------------------------------------------------------------
const SHOP_GEMEIN = ['farbe', 'betrieb_name', 'anbieter_zeile', 'impressum_url', 'datenschutz_url', 'widerruf_url']
test('G09–G15: Platzhalter, Blöcke und Betreff wie im Plan (G-Vorlagen des Webshops haben keine Anrede-Blöcke)', () => {
const erwartet = {
G09: {
s: 'Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}}',
p: ['bestellnummer', 'datum', 'zahlungsdatum', 'positionen', 'zwischensumme', 'versand', 'gesamt', 'steuer_hinweis', 'lieferart_text', 'lieferadresse', 'abholort', 'lieferzeit', 'zahlungsart', 'ausschluss_text', 'agb', 'widerrufsbelehrung', 'widerrufsformular', 'gewaehrleistung'],
b: ['versand', 'abholung', 'ausschluss'],
},
G10: { s: 'Ihre Bestellung {{bestellnummer}} wurde versendet', p: ['bestellnummer', 'sendungsnummer', 'versanddienst'], b: ['sendung'] },
G11: { s: 'Ihre Bestellung {{bestellnummer}} ist abholbereit', p: ['bestellnummer', 'abholort', 'hinweis'], b: [] },
G12: { s: 'Eingangsbestätigung Ihres Widerrufs bei {{betrieb_name}}', p: ['name', 'bestellnummer', 'eingang', 'nachricht'], b: ['nachricht'] },
G13: { s: 'Erstattung zu Ihrer Bestellung {{bestellnummer}}', p: ['bestellnummer', 'betrag', 'grund'], b: ['grund'] },
G14: { s: 'Ihre Bestellung {{bestellnummer}} wurde nicht abgeschlossen', p: ['bestellnummer'], b: [] },
G15: { s: 'Wir haben Ihre Bestellung {{bestellnummer}} erhalten', p: ['bestellnummer', 'datum', 'positionen', 'gesamt'], b: [] },
}
assert.deepEqual(Object.keys(erwartet), SHOP_GUEST)
for (const [id, { s, p, b }] of Object.entries(erwartet)) {
const tpl = loadTemplate(id)
assert.equal(tpl.subject, s, id)
assert.deepEqual([...tpl.placeholders].sort(), [...p, ...SHOP_GEMEIN].sort(), `${id}: Platzhalter`)
assert.deepEqual([...tpl.blocks].sort(), [...b].sort(), `${id}: Blöcke`)
}
})
test('G09–G15: neutrales Layout in der Shop-Farbe, kein Logo, Fuß mit Anbieter, Impressum, Datenschutz und Widerrufsfunktion', () => {
for (const id of SHOP_GUEST) {
const tpl = loadTemplate(id)
const source = sourceOf(id)
assert.match(source, /<td[^>]*style="[^"]*background:\{\{farbe\}\}/, `${id}: Kopfbalken in {{farbe}}`)
assert.doesNotMatch(source, /<img|webklar-logo|project\.webklar\.com/i, `${id}: kein Bild, kein WEBklar-Logo, keine Portal-Adresse`)
assert.doesNotMatch(source, /https?:\/\/(?!www\.w3\.org)|google|tracking|pixel/i, `${id}: keine festen Adressen, kein Tracking`)
for (const on of [true, false]) {
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, on))
assert.deepEqual(mail.problems, [], id)
assert.ok(mail.text.includes('Wert-anbieter_zeile'), `${id}: Anbieter im Fuß`)
assert.ok(mail.text.includes('Diese E-Mail wurde im Auftrag von Wert-betrieb_name über den Webshop von WEBklar versendet.'), `${id}: Fußsatz`)
assert.match(mail.html, /href="Wert-impressum_url"[^>]*>Impressum</, id)
assert.match(mail.html, /href="Wert-datenschutz_url"[^>]*>Datenschutz</, id)
assert.match(mail.html, /href="Wert-widerruf_url"[^>]*>Vertrag widerrufen</, id)
assert.match(mail.html, /background:Wert-farbe/, id)
assert.doesNotMatch(mail.text, DU_FORM, `${id}: Sie-Form, nicht Du\n${mail.text}`)
}
}
})
const G_WERTE = { bestellnummer: '26-K7QM2X', betrieb_name: 'Töpferei <Muster>', farbe: '#112233', anbieter_zeile: 'Töpferei Muster, Hauptstr. 1, 12345 Musterstadt, info@example.com', impressum_url: 'https://shop.example/impressum', datenschutz_url: 'https://shop.example/datenschutz', widerruf_url: 'https://project.webklar.com/shop/sk/widerruf' }
const g = (id, werte, blocks = {}) => renderMail(id, { ...G_WERTE, ...werte }, blocks)
const G09_WERTE = {
datum: '05.10.2026',
zahlungsdatum: '07.10.2026',
positionen: '2 × Tasse (blau) – 24,00 €\n1 × Teller – 12,50 €',
zwischensumme: '36,50 €',
versand: '4,90 €',
gesamt: '41,40 €',
steuer_hinweis: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
lieferart_text: 'Versand nach Deutschland',
lieferadresse: 'Erika Beispiel\nMusterweg 3\n12345 Musterstadt',
abholort: 'Werkstatt, Hauptstr. 1',
lieferzeit: '2–4 Werktage',
zahlungsart: 'Kreditkarte',
ausschluss_text: 'Teller: Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt',
agb: 'AGB-Text Zeile 1\nAGB-Text Zeile 2',
widerrufsbelehrung: 'Widerrufsbelehrung Zeile 1\nZeile 2',
widerrufsformular: 'An Töpferei Muster\nHiermit widerrufe ich',
gewaehrleistung: 'GESETZLICHE GEWÄHRLEISTUNG\n\nMindestens zwei Jahre',
}
test('G09 Versand: Betreff mit Bestellnummer und Shop, Positionen, Summen, Lieferadresse, alle Rechtstexte, Links', () => {
const mail = g('G09', G09_WERTE, { versand: true, abholung: false, ausschluss: true })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X bei Töpferei <Muster>')
assert.match(mail.html, /Töpferei &lt;Muster&gt;/, 'Shopname maskiert')
assert.match(mail.html, /2 × Tasse \(blau\) – 24,00 €<br>1 × Teller – 12,50 €/)
assert.match(mail.html, /Erika Beispiel<br>Musterweg 3<br>12345 Musterstadt/)
for (const teil of ['26-K7QM2X', '05.10.2026', '36,50 €', '4,90 €', '41,40 €', '§ 19 UStG', 'Versand nach Deutschland', '2–4 Werktage', 'Kreditkarte', 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt', 'AGB-Text Zeile 2', 'Widerrufsbelehrung Zeile 1', 'Hiermit widerrufe ich', 'GESETZLICHE GEWÄHRLEISTUNG', 'Hinweis zur gesetzlichen Gewährleistung', 'Muster-Widerrufsformular', 'Allgemeine Geschäftsbedingungen']) {
assert.ok(mail.text.includes(teil), teil)
}
// I-1 b: ausdrückliche Annahme, Bestelldatum und Zahlungsdatum getrennt
assert.match(mail.text, /Mit dieser E-Mail nehmen wir Ihre Bestellung an\. Der Kaufvertrag ist damit geschlossen\./)
assert.match(mail.text, /Bestelldatum: 05\.10\.2026/)
assert.match(mail.text, /Zahlungsdatum: 07\.10\.2026/)
assert.match(mail.text, /Lieferadresse: Erika Beispiel/)
assert.doesNotMatch(mail.text, /Abholort|Werkstatt, Hauptstr/)
assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/shop\/sk\/widerruf"/)
})
test('G09 Abholung ohne Ausschluss: Abholort statt Lieferadresse, kein Widerrufs-Ausschluss', () => {
const mail = g('G09', { ...G09_WERTE, lieferart_text: 'Abholung', versand: 'Abholung – kostenlos' }, { versand: false, abholung: true, ausschluss: false })
assert.deepEqual(mail.problems, [])
assert.match(mail.text, /Abholort: Werkstatt, Hauptstr\. 1/)
assert.doesNotMatch(mail.text, /Lieferadresse|Musterweg|Hinweis zum Widerrufsrecht|ausgeschlossen/)
assert.match(mail.text, /Abholung – kostenlos/)
})
test('G15: Eingang ohne Vertragsschluss - Lastschrift in Bearbeitung, Bestellbestätigung folgt, Positionen und Gesamt', () => {
const mail = g('G15', { datum: '05.10.2026', positionen: '2 × Tasse (blau) – 24,00 €', gesamt: '41,40 €' })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Wir haben Ihre Bestellung 26-K7QM2X erhalten')
assert.equal(loadTemplate('G15').file, 'G15 Bestellung eingegangen.html')
for (const teil of ['26-K7QM2X', '05.10.2026', '2 × Tasse (blau) – 24,00 €', '41,40 €', 'Ihre Zahlung per Lastschrift ist noch in Bearbeitung. Sobald sie bei uns eingegangen ist, erhalten Sie die Bestellbestätigung; erst damit kommt der Kaufvertrag zustande.']) {
assert.ok(mail.text.includes(teil), teil)
}
assert.doesNotMatch(mail.text, /Kaufvertrag ist damit geschlossen|Widerrufsbelehrung|Allgemeine Geschäftsbedingungen/)
})
test('G10: Sendungsnummer und Versanddienst nur im Block sendung', () => {
const mit = g('G10', { sendungsnummer: '0034<1>', versanddienst: 'DHL' }, { sendung: true })
assert.deepEqual(mit.problems, [])
assert.equal(mit.subject, 'Ihre Bestellung 26-K7QM2X wurde versendet')
assert.match(mit.text, /Versanddienst: DHL/)
assert.match(mit.text, /Sendungsnummer: 0034<1>/)
assert.match(mit.html, /0034&lt;1&gt;/)
const ohne = g('G10', { sendungsnummer: '', versanddienst: '' }, {})
assert.deepEqual(ohne.problems, [])
assert.doesNotMatch(ohne.text, /Sendungsnummer|Versanddienst/)
assert.match(ohne.text, /versendet/)
})
test('G11: Abholort und Hinweis', () => {
const mail = g('G11', { abholort: 'Werkstatt, Hauptstr. 1', hinweis: 'Di–Fr 10–16 Uhr' })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X ist abholbereit')
assert.match(mail.text, /Abholort: Werkstatt, Hauptstr\. 1/)
assert.match(mail.text, /Hinweis: Di–Fr 10–16 Uhr/)
})
test('G12: Eingang mit Datum und Uhrzeit, Erklärung nur im Block nachricht, Händler meldet sich', () => {
const werte = { name: 'Erika <E>', bestellnummer: '26-ZZZZZZ', eingang: '05.10.2026, 14:03 Uhr', nachricht: 'Bitte stornieren.\nDanke' }
const mit = g('G12', werte, { nachricht: true })
assert.deepEqual(mit.problems, [])
assert.equal(mit.subject, 'Eingangsbestätigung Ihres Widerrufs bei Töpferei <Muster>')
assert.match(mit.text, /Guten Tag Erika <E>,/)
assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/)
assert.match(mit.text, /Bestellnummer: 26-ZZZZZZ/)
assert.match(mit.html, /Bitte stornieren\.<br>Danke/)
assert.match(mit.text, /meldet sich bei Ihnen/)
const ohne = g('G12', werte, {})
assert.doesNotMatch(ohne.text, /Bitte stornieren|Ihre Erklärung:/)
})
test('G13: Betrag, Grund nur im Block, Gutschrift über die ursprüngliche Zahlungsart', () => {
const mit = g('G13', { betrag: '12,50 €', grund: 'Artikel beschädigt' }, { grund: true })
assert.deepEqual(mit.problems, [])
assert.equal(mit.subject, 'Erstattung zu Ihrer Bestellung 26-K7QM2X')
assert.match(mit.text, /Erstatteter Betrag: 12,50 €/)
assert.match(mit.text, /Grund: Artikel beschädigt/)
assert.match(mit.text, /Die Gutschrift erfolgt über die ursprüngliche Zahlungsart\./)
assert.doesNotMatch(g('G13', { betrag: '12,50 €', grund: 'x' }, {}).text, /Grund:/)
})
test('G14: nichts berechnet, kein Betrag', () => {
const mail = g('G14', {})
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X wurde nicht abgeschlossen')
assert.match(mail.text, /wurde nicht abgeschlossen; es wurde nichts berechnet\./)
})
const K25_WERTE = { kunde_name: 'Erika', shopname: 'Töpferei', bestellnummer: '26-K7QM2X', gesamt: '41,40 €', positionen: '2 × Tasse – 24,00 €\n1 × Teller – 12,50 €', lieferart_text: 'Versand nach Deutschland', kaeufer_name: 'Max <M>', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' }
test('K25: Betreff, Platzhalter, Blöcke; Bestand-Warnung und Lastschrift-Hinweis nur im Block', () => {
const tpl = loadTemplate('K25')
assert.equal(tpl.file, 'K25 Neue Bestellung.html')
assert.equal(tpl.subject, 'Neue Bestellung {{bestellnummer}} in Deinem Webshop')
assert.deepEqual([...tpl.placeholders].sort(), Object.keys(K25_WERTE).sort())
assert.deepEqual(tpl.blocks, ['ueberverkauft', 'sepa'])
const normal = renderMail('K25', K25_WERTE, {})
assert.deepEqual(normal.problems, [])
assert.equal(normal.subject, 'Neue Bestellung 26-K7QM2X in Deinem Webshop')
assert.match(normal.text, /Hallo Erika/)
assert.match(normal.text, /Käufer: Max <M>/)
assert.match(normal.html, /2 × Tasse – 24,00 €<br>1 × Teller – 12,50 €/)
assert.match(normal.html, /href="https:\/\/portal\.example\/dashboard\.html#webshop\?projekt=p1"/)
assert.doesNotMatch(normal.text, /Achtung|Lastschrift/)
const voll = renderMail('K25', K25_WERTE, { ueberverkauft: true, sepa: true })
assert.match(voll.text, /Achtung: Bestand reichte nicht/)
assert.match(voll.text, /Zahlung per Lastschrift/)
assert.doesNotMatch(renderMail('K25', K25_WERTE, { sepa: true }).text, /Achtung/)
assert.doesNotMatch(renderMail('K25', K25_WERTE, { ueberverkauft: true }).text, /Lastschrift/)
})
const K26_WERTE = { kunde_name: 'Erika', shopname: 'Töpferei', bestellnummer: '26-K7QM2X', kaeufer_name: 'Max <M>', kaeufer_email: 'max@example.com', eingang: '05.10.2026, 14:03 Uhr', nachricht: 'Ich widerrufe.\nBitte melden', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' }
test('K26: Betreff, Platzhalter, Erklärung nur im Block, Hinweis auf die 14 Tage', () => {
const tpl = loadTemplate('K26')
assert.equal(tpl.file, 'K26 Widerruf im Webshop.html')
assert.equal(tpl.subject, 'Widerruf in Deinem Webshop eingegangen')
assert.deepEqual([...tpl.placeholders].sort(), Object.keys(K26_WERTE).sort())
assert.deepEqual(tpl.blocks, ['nachricht'])
const mit = renderMail('K26', K26_WERTE, { nachricht: true })
assert.deepEqual(mit.problems, [])
assert.match(mit.text, /Hallo Erika/)
assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/)
assert.match(mit.text, /E-Mail: max@example\.com/)
assert.match(mit.html, /Ich widerrufe\.<br>Bitte melden/)
assert.match(mit.text, /innerhalb von 14 Tagen/)
assert.doesNotMatch(renderMail('K26', K26_WERTE, {}).text, /Ich widerrufe|Erklärung:/)
})
+11
View File
@@ -67,3 +67,14 @@ test('Route: anderer Kunde bleibt ausgesperrt', async () => {
assert.equal(r.allowed, false)
assert.equal(r.reason, 'no_project')
})
test('Route: archiviertes Projekt ist fuer den Kunden gesperrt (Entscheidung 2026-10-04)', async () => {
const doc = aw.all('websiteProjects').find((d) => d.$id === 'p1')
doc.archived = true
try {
const r = await access(token.c1)
assert.equal(r.allowed, false)
assert.equal(r.reason, 'no_project')
} finally {
doc.archived = false
}
})
+26
View File
@@ -103,6 +103,32 @@ test('Schalter aus: kein Runner-Aufruf, alter Pfad', async () => {
assert.equal(rs.calls.length, 0)
})
test('fremde Subdomain (kopierte Config): kein Runner-Auftrag, Projekt unverändert', async () => {
const original = await project({ subdomain: 'original-seite' })
const kopie = await project()
const r = await pd.runDeploy(kopie.repo, 'main', { ...cfg(kopie.repo), subdomain: 'original-seite' }, { project: kopie.doc, sha: SHA })
assert.equal(r.blocked, true)
assert.equal(r.owner, original.repo)
assert.equal(submits().length, 0)
assert.equal(aw.get('websiteProjects', kopie.id).previewState, undefined)
// danach laeuft ein normaler Auftrag fuer dasselbe Repo wieder an
const ok = await pd.runDeploy(kopie.repo, 'main', cfg(kopie.repo), { project: kopie.doc, sha: SHA })
assert.equal(ok.queued, true)
await pd.waitForRunFollowers()
})
test('gesperrter Aufruf waehrend eines laufenden Baus: der Verfolger bringt den Bau trotzdem online', async () => {
await project({ subdomain: 'andere-seite' })
const { id, repo, doc } = await project()
const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
assert.equal(r.queued, true)
const blocked = await pd.runDeploy(repo, 'main', { ...cfg(repo), subdomain: 'andere-seite' }, { project: doc, sha: SHA })
assert.equal(blocked.blocked, true)
await pd.waitForRunFollowers()
assert.equal(aw.get('websiteProjects', id).previewState, 'online')
assert.equal(submits().length, 1)
})
test('an: Auftrag mit entschlüsselten Variablen, sofortige Rückgabe, Ergebnis online', async () => {
const { id, repo, doc } = await project({ projectName: 'Mein Projekt', previewEnv: envLib.encryptEnv({ API_URL: 'https://x.test' }, key) })
const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
+253
View File
@@ -0,0 +1,253 @@
// Ablauf Projekt anlegen / archivieren / Import im Ticketsystem (Fehler aus dem Live-Durchlauf 2026-10-04):
// - "Neues Projekt" ohne Subdomain schrieb subdomain '' -> Unique-Index -> "Document with the requested ID already exists"
// - Wettlauf mit dem Webhook repository/created beim Anlegen
// - Reaktivieren liess status 'archived' stehen
// - archivierte Repos kamen nie ins Ticketsystem, Archiv-Stand aus Gitea nie an
// - "Gitea-Repos synchronisieren" ueberschrieb Projektnamen
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { startFakeGitea } from './helpers/fakeGitea.js'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
let gitea
let aw
let config
let svc
let handleRepositoryCreated
let upsertWebsiteProjectByRepo
const docs = () => aw.all('websiteProjects')
const docOf = (name) => docs().find((d) => String(d.repoFullName).toLowerCase() === name.toLowerCase())
before(async () => {
gitea = await startFakeGitea({
repos: [
{ full_name: 'knso/webklar-preview-template' },
{ full_name: 'knso/gradient-component' },
{ full_name: 'WEBklar/ohne-site' },
{ full_name: 'WEBklar/mit-site' },
{ full_name: 'WEBklar/D-ner-Nation', description: 'D-ner-Nation' },
],
})
aw = await startFakeAppwrite({
unique: { websiteProjects: ['repoFullName', 'subdomain'] },
seed: {
websiteProjects: [
// wie live: ein altes Projekt traegt subdomain '' (danach scheiterte jedes weitere ohne Website)
{ repoFullName: 'knso/gradient-component', projectName: 'gradient component', subdomain: '' },
{ $id: 'p_ohne', repoFullName: 'WEBklar/ohne-site', projectName: 'Ohne Site', status: 'created' },
{ $id: 'p_mit', repoFullName: 'WEBklar/mit-site', projectName: 'Mit Site', subdomain: 'mit-site', status: 'deployed' },
{ $id: 'p_doener', repoFullName: 'WEBklar/D-ner-Nation', projectName: 'Döner Nation', subdomain: 'doener-nation' },
],
customers: [{ $id: 'c1', name: 'Kunde' }],
},
})
Object.assign(process.env, {
GITEA_BASE_URL: gitea.baseUrl,
GITEA_API_TOKEN: 'test-token',
GITEA_DEFAULT_OWNER: 'WEBklar',
GITEA_TEMPLATE_OWNER: 'knso',
GITEA_TEMPLATE_REPO: 'webklar-preview-template',
APPWRITE_ENDPOINT: aw.endpoint,
APPWRITE_API_KEY: 'test-key',
PREVIEW_DEPLOY_ROOT: '',
TRAEFIK_DYNAMIC_DIR: '',
})
;({ config } = await import('../server/config.js'))
svc = await import('../server/services/giteaAdmin.js')
;({ handleRepositoryCreated } = await import('../server/services/repoAssignJob.js'))
;({ upsertWebsiteProjectByRepo } = await import('../server/services/appwriteAdmin.js'))
})
after(async () => {
await gitea.close()
await aw.close()
})
test('Neues Projekt ohne Subdomain: kein subdomain-Konflikt mehr, Kunde bleibt dran', async () => {
const r = await svc.createProjectFromTemplate({ displayName: 'Zweites ohne Website', customerId: 'c1' })
assert.equal(r.repoFullName, 'WEBklar/zweites-ohne-website')
assert.equal(r.projectType, 'repo')
const d = docOf('WEBklar/zweites-ohne-website')
assert.equal(d.subdomain, null)
assert.equal(d.customerId, 'c1')
assert.equal(d.projectName, 'Zweites ohne Website')
// Gitea-Beschreibung = Anzeigename (Abgleich/Webhook lesen den Namen von dort)
const gen = gitea.calls.find((c) => c.method === 'POST' && c.path.endsWith('/generate'))
assert.equal(gen.body.description, 'Zweites ohne Website')
// Kundenquelltext nicht oeffentlich (Entscheidung 2026-10-04)
assert.equal(gen.body.private, true)
})
test('Neues Projekt: Webhook repository/created legt das Dokument zuerst an -> eines, mit Kunde und Ticket', async () => {
gitea.setOnGenerate(async (repo) => {
await handleRepositoryCreated({ action: 'created', repository: repo, sender: { login: 'webklar-system' } }, {
sendMail: async () => ({ sent: false }),
})
})
try {
const r = await svc.createProjectFromTemplate({ displayName: 'Webhook zuerst', customerId: 'c1', ticketId: 't1' })
const all = docs().filter((d) => d.repoFullName === r.repoFullName)
assert.equal(all.length, 1)
assert.equal(all[0].customerId, 'c1')
assert.equal(all[0].ticketId, 't1')
assert.equal(all[0].projectType, 'repo')
assert.equal(all[0].createdByGitea, 'webklar-system')
} finally {
gitea.setOnGenerate(null)
}
})
test('Wettlauf zwischen Suche und Anlage: 409 wird zum Update statt zur Fehlermeldung', async () => {
let raced = false
aw.setBeforeCreate((col, data) => {
if (col !== 'websiteProjects' || raced || data.repoFullName !== 'WEBklar/wettlauf') return
raced = true
aw.insert('websiteProjects', { repoFullName: 'WEBklar/wettlauf', projectName: 'wettlauf', status: 'synced' })
})
try {
const d = await upsertWebsiteProjectByRepo('WEBklar/wettlauf', { repoFullName: 'WEBklar/wettlauf', customerId: 'c1', projectName: 'Wettlauf' })
assert.ok(raced)
assert.equal(d.customerId, 'c1')
assert.equal(docs().filter((x) => x.repoFullName === 'WEBklar/wettlauf').length, 1)
} finally {
aw.setBeforeCreate(null)
}
})
test('Neues Projekt mit Art (Ticket): kind gesetzt; unbekannte Art -> 400 ohne Repo', async () => {
await svc.createProjectFromTemplate({ displayName: 'Mit Art', customerId: 'c1', ticketId: 't2', kind: 'website', staffName: 'Kenso' })
const d = docOf('WEBklar/mit-art')
assert.deepEqual([d.kind, d.forCustomer, d.kindSetBy, d.previewMode], ['website', true, 'Kenso', 'auto'])
assert.ok(d.kindSetAt)
const before = gitea.calls.filter((c) => c.path.endsWith('/generate')).length
await assert.rejects(svc.createProjectFromTemplate({ displayName: 'Falsch', kind: 'quatsch' }), (err) => err.status === 400)
assert.equal(gitea.calls.filter((c) => c.path.endsWith('/generate')).length, before)
})
test('Typ "Internes Projekt" schreibt null statt leerer Subdomain', async () => {
const r = await svc.updateWebsiteProject('p_mit', { projectType: 'project' })
assert.equal(r.subdomain, '')
assert.equal(aw.get('websiteProjects', 'p_mit').subdomain, null)
// fuer die folgenden Tests wieder als Website
await svc.updateWebsiteProject('p_mit', { projectType: 'preview', subdomain: 'mit-site' })
assert.equal(aw.get('websiteProjects', 'p_mit').subdomain, 'mit-site')
})
test('Archivieren und Reaktivieren ohne Website: status geht zurueck auf synced', async () => {
await svc.archiveRepo('p_ohne', true)
assert.deepEqual([aw.get('websiteProjects', 'p_ohne').archived, aw.get('websiteProjects', 'p_ohne').status], [true, 'archived'])
assert.equal(gitea.repo('WEBklar/ohne-site').archived, true)
await svc.archiveRepo('p_ohne', false)
assert.deepEqual([aw.get('websiteProjects', 'p_ohne').archived, aw.get('websiteProjects', 'p_ohne').status], [false, 'synced'])
assert.equal(gitea.repo('WEBklar/ohne-site').archived, false)
})
test('Reaktivieren mit Website: status nicht mehr archived (pending bis der Deploy meldet)', async () => {
await svc.archiveRepo('p_mit', true)
assert.equal(aw.get('websiteProjects', 'p_mit').status, 'archived')
await svc.archiveRepo('p_mit', false)
const d = aw.get('websiteProjects', 'p_mit')
assert.equal(d.archived, false)
// hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy -> 'pending' und als fehlgeschlagen markiert
assert.deepEqual([d.status, d.provisioningStatus], ['pending', 'deploy_failed'])
// der Status wird vor dem Redeploy geschrieben, danach nicht mehr (der Runner setzt 'deployed')
const patches = aw.calls.filter((c) => c.method === 'PATCH' && c.path.endsWith('/p_mit'))
assert.equal(patches.at(-1).body.data.status, undefined)
})
test('Neues Projekt: alter verknuepfter Eintrag mit demselben Namen blockiert mit klarer Meldung, bevor ein Repo entsteht', async () => {
aw.insert('websiteProjects', { repoFullName: 'WEBklar/alt-belegt', projectName: 'Alt', archived: true, status: 'archived', kind: 'website', customerId: 'cAlt' })
const before = gitea.calls.filter((c) => c.path.endsWith('/generate')).length
await assert.rejects(svc.createProjectFromTemplate({ displayName: 'alt belegt', repoName: 'alt-belegt' }), (err) =>
err.status === 409 && /alten Projekt/.test(err.message) && /archiviert/.test(err.message))
assert.equal(gitea.calls.filter((c) => c.path.endsWith('/generate')).length, before)
})
test('Neues Projekt: alter Platzhalter ohne Bezug wird entfernt, das neue Projekt startet sauber', async () => {
aw.insert('websiteProjects', { $id: 'platzhalter', repoFullName: 'WEBklar/frei-machen', projectName: 'alt', archived: true, status: 'archived' })
const r = await svc.createProjectFromTemplate({ displayName: 'Frei machen', customerId: 'c1' })
assert.equal(aw.get('websiteProjects', 'platzhalter'), undefined)
const d = docOf(r.repoFullName)
assert.equal(d.archived, false)
assert.equal(d.customerId, 'c1')
})
test('Abgleich ueberschreibt den Projektnamen nicht mehr', async () => {
await svc.syncReposToAppwrite()
assert.equal(aw.get('websiteProjects', 'p_doener').projectName, 'Döner Nation')
})
test('Import: archivierte Repos kommen als archiviert an (ohne Mail-Liste), Gitea-Archivstand wird gespiegelt, ein Fehler bricht nicht ab', async () => {
const g = await startFakeGitea({
repos: [
{ full_name: 'knso/neu-archiviert', archived: true, description: 'Alt' },
{ full_name: 'knso/kaputt' },
{ full_name: 'knso/neu-aktiv' },
{ full_name: 'knso/in-gitea-archiviert', archived: true },
{ full_name: 'knso/in-gitea-reaktiviert' },
],
})
const a = await startFakeAppwrite({
seed: {
websiteProjects: [
{ $id: 'x1', repoFullName: 'knso/in-gitea-archiviert', projectName: 'A', status: 'synced', archived: false },
{ $id: 'x2', repoFullName: 'knso/in-gitea-reaktiviert', projectName: 'B', status: 'archived', archived: true },
],
},
})
const original = { gitea: config.gitea.baseUrl, appwrite: config.appwrite.endpoint }
config.gitea.baseUrl = g.baseUrl
config.appwrite.endpoint = a.endpoint
const originalError = console.error
console.error = () => {}
try {
// erste Anlage (knso/kaputt kommt nach dem archivierten) schlaegt fehl
a.failNext({ method: 'POST', pathIncludes: '/collections/websiteProjects/documents', status: 400 })
const r = await svc.importNewRepos()
const byName = (n) => a.all('websiteProjects').find((d) => d.repoFullName === n)
// der erzwungene Fehler trifft die erste Anlage: das archivierte neue Repo
assert.deepEqual(r.failed, ['knso/neu-archiviert'])
assert.ok(byName('knso/kaputt'))
assert.ok(byName('knso/neu-aktiv'))
assert.deepEqual(r.createdRepos.map((x) => x.repo.full_name).sort(), ['knso/kaputt', 'knso/neu-aktiv'])
assert.equal(r.archiveSynced, 2)
assert.deepEqual([a.get('websiteProjects', 'x1').archived, a.get('websiteProjects', 'x1').status], [true, 'archived'])
assert.deepEqual([a.get('websiteProjects', 'x2').archived, a.get('websiteProjects', 'x2').status], [false, 'synced'])
// naechster Lauf holt das archivierte Repo nach - als archiviert, ohne Mail-Liste
const r2 = await svc.importNewRepos()
assert.equal(r2.archivedImported, 1)
assert.deepEqual(r2.createdRepos, [])
const arch = byName('knso/neu-archiviert')
assert.deepEqual([arch.archived, arch.status, arch.projectName], [true, 'archived', 'Alt'])
// dritter Lauf: nichts mehr zu tun
const r3 = await svc.importNewRepos()
assert.deepEqual([r3.created, r3.archivedImported, r3.archiveSynced, r3.failed.length], [0, 0, 0, 0])
} finally {
config.gitea.baseUrl = original.gitea
config.appwrite.endpoint = original.appwrite
console.error = originalError
await g.close()
await a.close()
}
})
test('Vorlage fuer "Neues Projekt" zieht beim Zuweisen nie um', async () => {
const { isInternalRepo } = await import('../server/services/repoMove.js')
assert.equal(await isInternalRepo('knso/webklar-preview-template'), true)
})
test('CORS erlaubt PUT (Preview-Variablen speichern)', async () => {
const { corsMiddleware } = await import('../server/middleware/cors.js')
const headers = {}
let status = 0
const req = { method: 'OPTIONS', get: (h) => (h === 'Origin' ? 'https://ticket.webklar.com' : undefined) }
const res = { setHeader: (k, v) => { headers[k] = v }, sendStatus: (s) => { status = s } }
corsMiddleware(['https://ticket.webklar.com'])(req, res, () => {})
assert.equal(status, 204)
assert.match(headers['Access-Control-Allow-Methods'], /\bPUT\b/)
})
+175 -34
View File
@@ -1,4 +1,4 @@
import { test, after, beforeEach } from 'node:test'
import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
@@ -149,52 +149,155 @@ test('K03/K04/T08/T09: Produkt des Kunden, Vertragsende nach § 11 der Fassung 4
assert.ok(k03u.text.includes('§ 11 der Vertragsbedingungen'), k03u.text)
})
// Terminplaner TP2 (E3): Website-Analyse und Terminplaner gehören zum Wartungs-Abo; K03 und K02 sagen, dass sie mit ihm enden
// Terminplaner TP2 (E3): Website-Analyse und Terminplaner gehören zum Wartungs-Abo; K03 und K02 sagen, dass sie mit ihm enden.
// Entscheidung 2026-10-05: nur, wenn der Kunde eine von beiden hat (Funktionsdokument analyse oder termine in einem Zustand ausser off).
const FUNKTIONEN_SATZ = 'Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm.'
const WARTUNG_V4 = { productKey: 'wartung', termsVersion: config.abo.termsVersion }
const WARTUNG = { patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } }
const k02 = () => bySubject('Bestätigung Ihrer Kündigung')
test('K03: Hinweis auf das Ende der Funktionen nur bei der Kündigung eines Wartungs-Abos', async () => {
await resetSubH({ patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } })
const FUNKTION_DOC = 'fk_funktion'
/** Fuehrt fn mit einem Funktionsdokument von c1 aus (doc null: keins; doc ueberschreibt die Vorgaben) und raeumt es danach auf. */
async function mitFunktion(doc, fn) {
if (doc) aw.seed('portalFeatures', { $id: FUNKTION_DOC, customerId: 'c1', projectId: 'fk_p', featureKey: 'analyse', state: 'active', enabled: doc.state === 'active', ...doc })
try {
return await fn()
} finally {
await deleteDocument(config.collections.portalFeatures, FUNKTION_DOC).catch(() => {})
}
}
/** K03: Abo-Kuendigung ueber die Kuendigungsseite; liefert die Mail. setup = Parameter von resetSubH. */
async function kuendigeMitK03(setup = {}) {
await resetSubH(setup)
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
assert.ok(k03().text.includes(FUNKTIONEN_SATZ), k03().text)
assert.ok(k03().text.includes('Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.'), k03().text)
assert.ok(k03(), 'K03')
return k03()
}
const gekuendigt = (id) => ({
id,
object: 'event',
type: 'customer.subscription.updated',
created: Math.floor(Date.now() / 1000),
data: { object: { id: 'sub_h', object: 'subscription', cancel_at_period_end: true, metadata: {} }, previous_attributes: { cancel_at_period_end: false } },
})
let k02Nummer = 0
/** K02: Kuendigung im Stripe-Kundenportal (Webhook); liefert die Mail. setup = Parameter von resetSubH. */
async function kuendigeMitK02(setup = {}) {
await resetSubH({ patch: { ...setup.patch, cancelAtPeriodEnd: true }, stripe: { cancel_at_period_end: true, ...setup.stripe } })
const res = await postWebhook(gekuendigt(`evt_k02_${++k02Nummer}`))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.ok(k02(), 'K02')
return k02()
}
// Hosting-Abo: weder Analyse noch Terminplaner
await resetSubH()
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
assert.doesNotMatch(k03().text, /Terminplaner/)
test('K03: Hinweis auf das Ende der Funktionen nur bei der Kündigung eines Wartungs-Abos mit Analyse oder Terminplaner', async () => {
await mitFunktion({ featureKey: 'analyse', state: 'active' }, async () => {
const mail = await kuendigeMitK03(WARTUNG)
assert.ok(mail.text.includes(FUNKTIONEN_SATZ), mail.text)
assert.ok(mail.text.includes('Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.'), mail.text)
// nur der Zusatzspeicher eines Wartungs-Abos gekündigt: das Abo läuft weiter, kein Hinweis
await resetSubH({ units: 3, patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } })
assert.equal((await cancel({ contract: 'zusatzspeicher' })).status, 200)
assert.doesNotMatch(k03().text, /Terminplaner/)
// Hosting-Abo: weder Analyse noch Terminplaner
assert.doesNotMatch((await kuendigeMitK03()).text, /Terminplaner/)
// unbekannte E-Mail (kein Abo gefunden): kein Hinweis
await resetSubH({ patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } })
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich', email: 'niemand@example.invalid' })).status, 200)
assert.doesNotMatch(bySubject('Eingangsbestätigung Ihrer Kündigung').text, /Terminplaner/)
// nur der Zusatzspeicher eines Wartungs-Abos gekündigt: das Abo läuft weiter, kein Hinweis
await resetSubH({ units: 3, ...WARTUNG })
assert.equal((await cancel({ contract: 'zusatzspeicher' })).status, 200)
assert.doesNotMatch(k03().text, /Terminplaner/)
// unbekannte E-Mail (kein Abo gefunden): kein Hinweis
await resetSubH(WARTUNG)
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich', email: 'niemand@example.invalid' })).status, 200)
assert.doesNotMatch(bySubject('Eingangsbestätigung Ihrer Kündigung').text, /Terminplaner/)
})
})
test('K02: Kündigung im Stripe-Kundenportal, Hinweis auf das Ende der Funktionen nur beim Wartungs-Abo', async () => {
const gekuendigt = (id) => ({
id,
object: 'event',
type: 'customer.subscription.updated',
created: Math.floor(Date.now() / 1000),
data: { object: { id: 'sub_h', object: 'subscription', cancel_at_period_end: true, metadata: {} }, previous_attributes: { cancel_at_period_end: false } },
// (a) bis (d): welche Funktionsdokumente des Kunden den Hinweis ausloesen
const FUNKTION_FAELLE = [
['ohne Funktionsdokument', null, false],
['aktive Analyse', { featureKey: 'analyse', state: 'active' }, true],
['Terminplaner im Zustand setup', { featureKey: 'termine', state: 'setup' }, true],
['Terminplaner bestellt (ordered)', { featureKey: 'termine', state: 'ordered' }, true],
['abgeschalteter Terminplaner (disabled)', { featureKey: 'termine', state: 'disabled' }, true],
['nur ein Terminplaner-Dokument im Zustand off', { featureKey: 'termine', state: 'off' }, false],
['nur ein Analyse-Dokument im Zustand off', { featureKey: 'analyse', state: 'off' }, false],
['Dokument ohne Zustand (gilt wie off)', { featureKey: 'termine', state: '' }, false],
['aktive News-Pinnwand (keine Funktion des Hinweises)', { featureKey: 'news', state: 'active' }, false],
['aktive Analyse eines anderen Kunden', { featureKey: 'analyse', state: 'active', customerId: 'c2' }, false],
]
for (const [name, doc, erwartet] of FUNKTION_FAELLE) {
const wort = erwartet ? 'mit' : 'ohne'
test(`K03, Wartungs-Abo: ${name} => ${wort} Hinweis`, async () => {
const mail = await mitFunktion(doc, () => kuendigeMitK03(WARTUNG))
assert.equal(mail.text.includes(FUNKTIONEN_SATZ), erwartet, mail.text)
assert.equal(/Terminplaner/.test(mail.text), erwartet)
assert.equal(/Terminplaner/.test(mail.html), erwartet, 'auch in der HTML-Fassung')
})
for (const [productKey, mitHinweis] of [['wartung', true], ['hosting', false]]) {
const patch = productKey === 'wartung' ? WARTUNG_V4 : {}
await resetSubH({ patch: { ...patch, cancelAtPeriodEnd: true }, stripe: { productKey, cancel_at_period_end: true, metadata: productKey === 'wartung' ? { termsVersion: config.abo.termsVersion } : {} } })
const res = await postWebhook(gekuendigt(`evt_k02_${productKey}`))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.ok(k02(), `${productKey}: K02`)
assert.equal(k02().text.includes(FUNKTIONEN_SATZ), mitHinweis, `${productKey}\n${k02().text}`)
assert.equal(/Terminplaner/.test(k02().text), mitHinweis, productKey)
test(`K02, Wartungs-Abo im Stripe-Kundenportal gekündigt: ${name} => ${wort} Hinweis`, async () => {
const mail = await mitFunktion(doc, () => kuendigeMitK02(WARTUNG))
assert.equal(mail.text.includes(FUNKTIONEN_SATZ), erwartet, mail.text)
assert.equal(/Terminplaner/.test(mail.text), erwartet)
assert.equal(/Terminplaner/.test(mail.html), erwartet, 'auch in der HTML-Fassung')
})
}
// (f) Hosting-Abo: nie ein Hinweis, auch nicht mit aktiver Analyse; die Funktionen werden dafuer gar nicht erst gelesen
test('K03 und K02, Hosting-Abo: kein Hinweis (auch mit aktiver Analyse), Funktionsdokumente werden nicht gelesen', async () => {
await mitFunktion({ featureKey: 'analyse', state: 'active' }, async () => {
aw.resetListCalls()
const mail03 = await kuendigeMitK03()
assert.doesNotMatch(mail03.text, /Terminplaner/)
const mail02 = await kuendigeMitK02({ stripe: { productKey: 'hosting' } })
assert.doesNotMatch(mail02.text, /Terminplaner/)
assert.equal(aw.listCalls('portalFeatures').length, 0, 'kein Lesen der Funktionen beim Hosting-Abo')
})
})
// (e) Lesefehler: lieber informieren als nicht; die Mail geht in jedem Fall raus, eine Zeile im Log
test('K03, Wartungs-Abo: Funktionen nicht lesbar => Hinweis bleibt, Mail geht raus, eine Logzeile', async () => {
const log = mock.method(console, 'error', () => {})
try {
await resetSubH(WARTUNG)
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau: Funktionen nicht lesbar' })
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
assert.ok(k03(), 'K03 geht trotzdem raus')
assert.ok(k03().text.includes(FUNKTIONEN_SATZ), k03().text)
assert.ok(t08(), 'T08 ans Team')
const zeilen = log.mock.calls.map((c) => String(c.arguments[0])).filter((z) => z.includes('Funktionen'))
assert.equal(zeilen.length, 1, zeilen.join('\n'))
assert.match(zeilen[0], /\[abo\] Funktionen/)
} finally {
log.mock.restore()
}
})
test('K02, Wartungs-Abo im Stripe-Kundenportal gekündigt: Funktionen nicht lesbar => Hinweis bleibt, Mail geht raus, eine Logzeile', async () => {
const log = mock.method(console, 'error', () => {})
try {
await resetSubH({ patch: { ...WARTUNG.patch, cancelAtPeriodEnd: true }, stripe: { cancel_at_period_end: true, ...WARTUNG.stripe } })
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau: Funktionen nicht lesbar' })
const res = await postWebhook(gekuendigt(`evt_k02_${++k02Nummer}`))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.ok(k02(), 'K02 geht trotzdem raus')
assert.ok(k02().text.includes(FUNKTIONEN_SATZ), k02().text)
assert.ok(aw.docs('subscriptions').get('sub_h').cancelRequestedAt, 'die Kündigung wird danach noch vermerkt')
const zeilen = log.mock.calls.map((c) => String(c.arguments[0])).filter((z) => z.includes('Funktionen'))
assert.equal(zeilen.length, 1, zeilen.join('\n'))
assert.ok(!log.mock.calls.some((c) => String(c.arguments[0]).includes('Kuendigungsbestaetigung')), 'kein Fehler der Bestätigung')
} finally {
log.mock.restore()
}
})
test('K02: Kündigung im Stripe-Kundenportal, Hinweis nur beim Wartungs-Abo mit Funktion (Hosting-Abo nie)', async () => {
await mitFunktion({ featureKey: 'termine', state: 'active' }, async () => {
for (const [productKey, mitHinweis] of [['wartung', true], ['hosting', false]]) {
const setup = productKey === 'wartung' ? WARTUNG : { patch: {}, stripe: { productKey, metadata: {} } }
const mail = await kuendigeMitK02(setup)
assert.equal(mail.text.includes(FUNKTIONEN_SATZ), mitHinweis, `${productKey}\n${mail.text}`)
assert.equal(/Terminplaner/.test(mail.text), mitHinweis, productKey)
}
})
})
test('2 von 3 Paketen ergibt Menge 1', async () => {
await resetSubH({ units: 3 })
const res = await cancel({ contract: 'zusatzspeicher', packages: '2' })
@@ -1007,6 +1110,43 @@ test('widerruf_termine_mails: K04 nennt „Terminplaner (Einrichtung)“, T09 de
}
})
test('widerruf_shop: Webshop (Einrichtung) rechnet mit der eigenen Frist, K04 und T09 nennen Webshop, Preis und Projekt, nichts wird automatisch geändert', async () => {
const shopAt = daysAgo(3)
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(20), state: 'active' })
seedBezahlt({ projectId: 'pt1', projektName: 'Studio Termine', featureKey: 'termine', orderedAt: daysAgo(2) })
seedBezahlt({ projectId: 'ps1', projektName: 'Bäckerei Shop', featureKey: 'shop', orderedAt: shopAt })
try {
await resetSubH({ units: 0 })
const before = st.calls.length
assert.equal((await withdraw({ order: 'shop', customerNumber: 'KD-9' })).status, 200)
const kunde = k04()
assert.match(kunde.subject, /WEBklar Webshop$/)
assert.match(kunde.text, /Widerruf der Bestellung „Webshop \(Einrichtung\)“/)
assert.match(kunde.text, /Wir prüfen Ihren Widerruf/)
assert.doesNotMatch(kunde.text, /Es erfolgen keine weiteren Abbuchungen/)
const team = t09().text
assert.match(team, /RECHTZEITIG/)
assert.doesNotMatch(team, /NACH Fristende/)
assert.match(team, /Webshop \(Einrichtung\)/)
assert.ok(team.includes(`Widerruf einer Funktion (${config.features.shop.label.name}, ${config.features.shop.priceLabel} einmalig)`), team)
assert.match(team, /299 €/)
assert.ok(team.includes(fmtDate(withdrawalDeadline(shopAt).endsAt)), 'Fristende des Webshops')
assert.match(team, /Bäckerei Shop: in Einrichtung/)
assert.doesNotMatch(team, /Bäckerei News|Studio Termine/, 'andere Funktionen gehören nicht zu dieser Wahl')
assert.match(team, /NICHTS automatisch geändert/)
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf')
assert.equal(aw.docs('portalFeatures').get('f_ps1_shop').state, 'setup', 'nichts automatisch geändert')
// ohne bezahlten Webshop: ungeprüft, die Antwort bleibt 200
await raeumeBezahlt('f_ps1_shop')
await resetSubH({ units: 0 })
assert.equal((await withdraw({ order: 'shop' })).status, 200)
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
} finally {
await raeumeBezahlt('f_pn1_news', 'f_pt1_termine', 'f_ps1_shop')
}
})
test('widerruf_zeiten: „Öffnungszeiten live“ gehört zu den Zeiten (nicht zur News), Frist und Produkt in K04 und T09 stimmen', async () => {
const newsAt = daysAgo(20)
const zeitenAt = daysAgo(3)
@@ -1313,6 +1453,7 @@ test('widerrufen_html_radio: Radioknöpfe order', () => {
assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/)
assert.match(src, /name="order" value="funktion"[^>]*> Funktion \(z\. B\. News-Pinnwand\)/)
assert.match(src, /name="order" value="termine"[^>]*data-text="Terminplaner \(Einrichtung\)"[^>]*> Terminplaner \(Einrichtung\)<\/label>/)
assert.match(src, /name="order" value="shop"[^>]*data-text="Webshop \(Einrichtung\)"[^>]*> Webshop \(Einrichtung\)<\/label>/)
assert.match(src, /name="order" value="zeiten"[^>]*data-text="Öffnungszeiten live"[^>]*> Öffnungszeiten live<\/label>/)
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)
+456
View File
@@ -0,0 +1,456 @@
import { test, after, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import http from 'node:http'
import path from 'node:path'
import express from 'express'
import sharp from 'sharp'
import { startAppwriteMock } from './support/appwriteMock.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Oeffentliche Schnittstelle des Webshops, lesender Teil (F1): Konfiguration samt Artikeln und Artikelbilder.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
},
datetimes: SHOP_DATETIMES,
defaultLimit: 25,
})
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const K = await import('../server/services/shop/kontext.js')
const { normalisiereProdukt } = await import('../server/services/shop/produkte.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { rechtstexteHash, GEWAEHRLEISTUNG_BILD } = await import('../server/services/shop/rechtstexte.js')
const { formToken, pruefeFormToken } = await import('../server/services/shop/token.js')
const { darfAnfrage, resetTermineLimitsForTests, zaehle } = await import('../server/services/termine/limits.js')
const { default: router, setzeUhrFuerTests, resetPublicShopForTests } = await import('../server/routes/publicShop.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/shop/v1': router })
const uploadDir = mkdtempSync(path.join(tmpdir(), 'shop-public-'))
after(() => Promise.all([app.close(), aw.close()]).then(() => rmSync(uploadDir, { recursive: true, force: true })))
const BASIS = '/api/public/shop/v1'
const JETZT = new Date('2026-10-05T12:00:00.000Z')
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_shop'
const SITE_KEY = 'abcdefghijklmnop'
const BILD_ID = 'b1b1b1b1-aaaa-bbbb-cccc-111122223333'
const FREMD_BILD = 'f0f0f0f0-aaaa-bbbb-cccc-111122223333'
const VORHER = {
mode: config.shop.mode,
pilot: [...config.shop.pilotEmails],
uploadDir: config.shop.uploadDir,
basis: config.publicBaseUrl,
formMin: config.shop.formMinSekunden,
}
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
kleinunternehmer: true,
lieferzeit: '2–4 Werktage',
shopname: 'Brot & Mehr',
akzent: '#aa3300',
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '0631 123', ustId: 'DE123456789' },
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 },
abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' },
zahlungsarten: ['card', 'sepa_debit'],
mindestbestellCent: 1000,
kleinunternehmer: false,
}
const TEXTE = {
agb: 'AGB-Text',
widerrufsbelehrung: 'Widerruf-Text',
widerrufsformular: 'Formular-Text',
impressumUrl: 'https://brot.example/impressum',
datenschutzUrl: 'https://brot.example/datenschutz',
}
const featureDoc = (over = {}) => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true, ...over })
const shopDoc = (over = {}) => ({
$id: FEATURE,
customerId: KUNDE,
projectId: PROJEKT,
siteKey: SITE_KEY,
anfrageStatus: 'angenommen',
stripeAccountId: 'acct_geheim123',
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }),
einstellungen: JSON.stringify(EINSTELLUNGEN),
rechtstexte: JSON.stringify({
entwurf: { ...TEXTE, agb: 'ENTWURF-AGB' },
bestaetigt: { texte: TEXTE, hash: rechtstexteHash(TEXTE, EINSTELLUNGEN), at: '2026-10-05T10:00:00.000Z', von: 'kunde@example.invalid' },
}),
geoeffnet: true,
version: 3,
...over,
})
let zaehler = 0
const artikel = (id, over = {}, produktOver = {}) => {
const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 450, ...produktOver }, { einstellungen: EINSTELLUNGEN })
assert.equal(r.fehler, undefined, JSON.stringify(r.fehler))
return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: ++zaehler, name: r.produkt.name, produkt: JSON.stringify({ ...r.produkt, ...(produktOver.bilder ? { bilder: produktOver.bilder } : {}) }), ...over }
}
async function aufbau({ feature = {}, shop = {}, produkte, kunde = { email: 'inhaber@brot.example' } } = {}) {
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects', 'customers']) {
for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id)
}
K.leereCache()
resetTermineLimitsForTests()
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen' })
if (kunde) aw.seed('customers', { $id: KUNDE, ...kunde })
aw.seed('portalFeatures', featureDoc(feature))
if (shop !== null) aw.seed('shopConfig', shopDoc(shop))
const liste = produkte ?? [
artikel('a1', {}, { kategorie: 'Brot', bilder: [BILD_ID] }),
artikel('a2', {}, { name: 'Zweites Brot', kategorie: 'Brot', bestand: 3 }),
artikel('a3', {}, { name: 'Kuchen', kategorie: 'Süßes' }),
artikel('a4', { aktiv: false }, { name: 'Verstecktes', kategorie: 'Geheim' }),
artikel('a5', {}, { name: 'Ohne Kategorie' }),
]
for (const p of liste) aw.seed('shopProducts', p)
}
const kopfzeile = (res, name) => res.headers.get(name)
let ip
let n = 0
const api = (pfad, { ip: adresse = ip, headers = {} } = {}) => send('GET', BASIS + pfad, { headers: { 'x-forwarded-for': adresse, ...headers } })
// Nichts Internes in irgendeiner Antwort
const VERBOTEN = ['customerId', 'projectId', 'featureId', 'acct_', 'stripeAccountId', 'stripeStatus', '$id', '$collectionId', '$createdAt', 'inhaber@brot.example', KUNDE, PROJEKT, FEATURE, 'DE123456789', 'ENTWURF-AGB']
const ohneInternes = (body) => {
const s = JSON.stringify(body)
for (const v of VERBOTEN) assert.ok(!s.includes(v), `interner Wert in der Antwort: ${v}`)
}
beforeEach(async () => {
zaehler = 0
ip = `10.1.${Math.floor(++n / 250)}.${(n % 250) + 1}`
config.shop.mode = 'on'
config.shop.pilotEmails = []
config.shop.uploadDir = uploadDir
config.publicBaseUrl = 'https://portal.example'
setzeUhrFuerTests(() => JETZT)
await aufbau()
})
afterEach(() => {
resetPublicShopForTests()
config.shop.mode = VORHER.mode
config.shop.pilotEmails = VORHER.pilot
config.shop.uploadDir = VORHER.uploadDir
config.publicBaseUrl = VORHER.basis
config.shop.formMinSekunden = VORHER.formMin
})
test('GET /:siteKey: Shop offen, Aufbau der Antwort und Kopfzeilen', async () => {
const res = await api(`/${SITE_KEY}`)
assert.equal(res.status, 200)
assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*')
assert.equal(kopfzeile(res, 'cache-control'), 'no-store')
assert.equal(kopfzeile(res, 'x-content-type-options'), 'nosniff')
const b = res.body
assert.equal(b.aktiv, true)
assert.equal(b.offen, true)
assert.equal(b.nurWiderruf, undefined)
assert.equal(b.shop.name, 'Brot & Mehr')
assert.equal(b.shop.akzent, '#aa3300')
assert.deepEqual(b.shop.anbieter, { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '0631 123' })
assert.deepEqual(b.shop.lieferlaender, [{ code: 'DE', name: 'Deutschland' }, { code: 'AT', name: 'Österreich' }])
assert.deepEqual(b.shop.abholung, { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' })
assert.equal(b.shop.lieferzeit, EINSTELLUNGEN.lieferzeit)
assert.equal(b.shop.zahlungsarten, 'Kreditkarte, Apple Pay, Google Pay, SEPA-Lastschrift')
assert.deepEqual(b.shop.zahlungsartenListe, ['Kreditkarte, Apple Pay, Google Pay', 'SEPA-Lastschrift'])
assert.equal(b.shop.versandText, 'Versand nach Deutschland 4,90 €, Österreich 9,90 €, ab 50,00 € versandkostenfrei')
assert.equal(b.shop.mindestbestellText, 'Mindestbestellwert 10,00 €')
assert.equal(b.shop.kleinunternehmer, false)
assert.equal(b.shop.steuerHinweisKurz, 'Alle Preise inkl. MwSt.')
assert.deepEqual(b.kategorien, ['Brot', 'Süßes'])
assert.deepEqual(b.produkte.map((p) => p.id), ['a1', 'a2', 'a3', 'a5'])
assert.equal(b.produkte[0].preisText, '4,50 €')
assert.deepEqual(b.produkte[0].bilder, [{ klein: `${BASIS}/${SITE_KEY}/bild/${BILD_ID}-s.webp`, gross: `${BASIS}/${SITE_KEY}/bild/${BILD_ID}-l.webp` }])
assert.equal(b.rechtstexte.agb, 'AGB-Text')
assert.equal(b.rechtstexte.widerrufsbelehrung, 'Widerruf-Text')
assert.equal(b.rechtstexte.widerrufsformular, 'Formular-Text')
assert.match(b.rechtstexte.versandUndZahlung, /Liefergebiet/)
assert.equal(b.rechtstexte.impressumUrl, 'https://brot.example/impressum')
assert.equal(b.rechtstexte.datenschutzUrl, 'https://brot.example/datenschutz')
assert.equal(b.gewaehrleistung.bild, `https://portal.example${GEWAEHRLEISTUNG_BILD}`)
assert.match(b.gewaehrleistung.text, /GESETZLICHE GEWÄHRLEISTUNG/)
assert.equal(b.widerrufUrl, `https://portal.example/shop/${SITE_KEY}/widerruf`)
ohneInternes(b)
})
test('Kleinunternehmer-Hinweis, nur Abholung, ohne Mindestbestellwert', async () => {
const e = { ...EINSTELLUNGEN, kleinunternehmer: true, mindestbestellCent: null, versand: { ...EINSTELLUNGEN.versand, aktiv: false } }
await aufbau({ shop: { einstellungen: JSON.stringify(e) } })
const b = (await api(`/${SITE_KEY}`)).body
assert.equal(b.shop.steuerHinweisKurz, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
assert.equal(b.shop.kleinunternehmer, true)
assert.deepEqual(b.shop.lieferlaender, [])
assert.equal(b.shop.versandText, 'Nur Abholung')
assert.equal(b.shop.mindestbestellText, '')
})
test('Formular-Token der Antwort gehoert zum siteKey und ist nach der Wartezeit gueltig', async () => {
const b = (await api(`/${SITE_KEY}`)).body
assert.match(b.formToken, /^[0-9a-z]+\.[A-Za-z0-9_-]{43}$/)
const spaeter = new Date(JETZT.getTime() + 10_000)
assert.equal(pruefeFormToken(SITE_KEY, b.formToken, spaeter), 'ok')
assert.equal(pruefeFormToken(SITE_KEY, b.formToken, new Date(JETZT.getTime() + 1000)), 'zu_schnell')
assert.equal(pruefeFormToken('zzzzzzzzzzzzzzzz', b.formToken, spaeter), 'ungueltig')
assert.equal(pruefeFormToken(SITE_KEY, `${b.formToken}x`, spaeter), 'ungueltig')
assert.equal(pruefeFormToken(SITE_KEY, b.formToken, new Date(JETZT.getTime() + 3 * 3600_000)), 'abgelaufen')
config.shop.formMinSekunden = 0
assert.equal(pruefeFormToken(SITE_KEY, formToken(SITE_KEY, JETZT), JETZT), 'ok')
})
test('Reservierungen offener Bestellungen mindern den Bestand in der Antwort', async () => {
const bestand = (await api(`/${SITE_KEY}`)).body.produkte.find((p) => p.id === 'a2')
assert.equal(bestand.verfuegbar, true)
aw.seed('shopOrders', {
$id: 'o_1',
shopId: FEATURE,
status: 'offen',
ablaufAt: new Date(JETZT.getTime() + 10 * 60_000).toISOString(),
bestellung: JSON.stringify({ positionen: [{ produktId: 'a2', menge: 3 }] }),
})
const ausverkauft = (await api(`/${SITE_KEY}`)).body.produkte.find((p) => p.id === 'a2')
assert.equal(ausverkauft.verfuegbar, false)
})
test('noch keine bestaetigten Rechtstexte: rechtstexte null, Shop geschlossen', async () => {
await aufbau({ shop: { rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: null }) } })
const b = (await api(`/${SITE_KEY}`)).body
assert.equal(b.aktiv, true)
assert.equal(b.offen, false)
assert.equal(b.rechtstexte, null)
assert.ok(b.produkte.length > 0)
})
test('nicht geoeffnet (Haendler hat nicht geoeffnet): offen false', async () => {
await aufbau({ shop: { geoeffnet: false } })
const b = (await api(`/${SITE_KEY}`)).body
assert.equal(b.aktiv, true)
assert.equal(b.offen, false)
})
test('abgeschalteter Shop (disabled): nur Widerruf, keine Artikel', async () => {
await aufbau({ feature: { state: 'disabled' } })
const res = await api(`/${SITE_KEY}`)
assert.equal(res.status, 200)
assert.deepEqual(Object.keys(res.body).sort(), ['aktiv', 'formToken', 'nurWiderruf', 'offen', 'shop', 'widerrufUrl'])
assert.equal(res.body.aktiv, true)
assert.equal(res.body.offen, false)
assert.equal(res.body.nurWiderruf, true)
assert.deepEqual(Object.keys(res.body.shop).sort(), ['akzent', 'anbieter', 'name'])
assert.equal(res.body.shop.name, 'Brot & Mehr')
assert.equal(res.body.widerrufUrl, `https://portal.example/shop/${SITE_KEY}/widerruf`)
ohneInternes(res.body)
})
test('aktiv:false ohne Orakel: unbekannt, ungueltig, falscher Zustand, Modus off', async () => {
const nein = async (pfad) => {
const res = await api(pfad)
assert.equal(res.status, 200, pfad)
assert.deepEqual(res.body, { aktiv: false }, pfad)
assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*')
}
await nein('/zzzzzzzzzzzzzzzz') // unbekannt
await nein('/abc') // kein siteKey
await nein('/ABCDEFGHIJKLMNOP') // falsche Schreibweise
await aufbau({ feature: { state: 'ordered' } })
await nein(`/${SITE_KEY}`)
await aufbau({ feature: { state: 'off' } })
await nein(`/${SITE_KEY}`)
await aufbau({ shop: null }) // kein shopConfig
await nein(`/${SITE_KEY}`)
await aufbau()
config.shop.mode = 'on'
assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true)
})
test('M-2: Modus off und Pilotkreis ohne Besitzer sperren das Bestellen, nicht die Widerrufsseite: GET liefert nurWiderruf, Bestellrouten bleiben aktiv:false', async () => {
const nurWiderruf = (body) => {
assert.equal(body.aktiv, true)
assert.equal(body.offen, false)
assert.equal(body.nurWiderruf, true)
assert.ok(body.formToken && body.widerrufUrl.endsWith(`/shop/${SITE_KEY}/widerruf`))
assert.equal(body.shop.name, 'Brot & Mehr')
assert.equal(body.produkte, undefined)
assert.equal(body.rechtstexte, undefined)
}
config.shop.mode = 'off'
nurWiderruf((await api(`/${SITE_KEY}`)).body)
assert.equal((await api(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`)).status, 404)
K.leereCache()
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['andere@example.com']
nurWiderruf((await api(`/${SITE_KEY}`)).body)
K.leereCache()
config.shop.pilotEmails = ['inhaber@brot.example']
assert.equal((await api(`/${SITE_KEY}`)).body.nurWiderruf, undefined, 'im Pilotkreis wie gewohnt')
// unbekannter Shop und andere Zustaende bleiben aktiv:false, auch bei off
config.shop.mode = 'off'
await aufbau({ feature: { state: 'off' } })
assert.deepEqual((await api(`/${SITE_KEY}`)).body, { aktiv: false })
assert.deepEqual((await api('/zzzzzzzzzzzzzzzz')).body, { aktiv: false })
})
test('Pilotmodus: nur Besitzer auf der Pilotliste bekommen den Shop, alle anderen nur die Widerrufsseite', async () => {
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['andere@example.com']
const gesperrt = (await api(`/${SITE_KEY}`)).body
assert.deepEqual([gesperrt.aktiv, gesperrt.nurWiderruf, gesperrt.produkte], [true, true, undefined])
K.leereCache()
config.shop.pilotEmails = ['inhaber@brot.example']
assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true)
assert.equal((await api(`/${SITE_KEY}`)).body.nurWiderruf, undefined)
K.leereCache()
config.shop.pilotEmails = ['*']
assert.equal((await api(`/${SITE_KEY}`)).body.nurWiderruf, undefined)
// Kunde ohne Dokument: nie freigeschaltet
await aufbau({ kunde: null })
config.shop.pilotEmails = ['inhaber@brot.example']
assert.equal((await api(`/${SITE_KEY}`)).body.nurWiderruf, true)
// im Pilotmodus gilt dasselbe fuer die Bilder
await aufbau()
config.shop.pilotEmails = ['andere@example.com']
assert.equal((await api(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`)).status, 404)
})
// ---------------------------------------------------------------- Bilder
async function legeBildAb(id = BILD_ID, siteKey = SITE_KEY) {
const ordner = path.join(uploadDir, siteKey)
mkdirSync(ordner, { recursive: true })
const webp = await sharp({ create: { width: 4, height: 4, channels: 3, background: '#cc3300' } }).webp().toBuffer()
writeFileSync(path.join(ordner, `${id}-s.webp`), webp)
writeFileSync(path.join(ordner, `${id}-l.webp`), webp)
writeFileSync(path.join(ordner, `${id}.json`), JSON.stringify({ customerId: KUNDE, shopId: FEATURE, createdAt: JETZT.toISOString(), claimed: true, w: 4, h: 4 }))
return webp
}
test('Bild: Auslieferung mit Cache, nosniff und CORS', async () => {
const webp = await legeBildAb()
for (const g of ['s', 'l']) {
const res = await fetch(`${app.url}${BASIS}/${SITE_KEY}/bild/${BILD_ID}-${g}.webp`, { headers: { 'x-forwarded-for': ip } })
assert.equal(res.status, 200)
assert.equal(res.headers.get('content-type'), 'image/webp')
assert.equal(res.headers.get('cache-control'), 'public, max-age=86400')
assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
assert.equal(res.headers.get('access-control-allow-origin'), '*')
assert.deepEqual(Buffer.from(await res.arrayBuffer()), webp)
}
})
test('Bild: 404 bei fremdem, unbekanntem, inaktivem Artikel, schlechtem Namen und fehlender Datei', async () => {
await legeBildAb()
await legeBildAb(FREMD_BILD) // liegt im Ordner, gehoert aber zu keinem Artikel
const vier = async (pfad) => {
const res = await api(pfad)
assert.equal(res.status, 404, pfad)
assert.deepEqual(res.body, { fehler: { _: 'Nicht gefunden' } }, pfad)
assert.equal(kopfzeile(res, 'cache-control'), 'no-store')
return res
}
await vier(`/${SITE_KEY}/bild/${FREMD_BILD}-s.webp`)
await vier(`/${SITE_KEY}/bild/${BILD_ID}-m.webp`) // Groesse
await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.png`)
await vier(`/${SITE_KEY}/bild/..%2F${BILD_ID}-s.webp`)
await vier(`/${SITE_KEY}/bild/${BILD_ID.toUpperCase()}-s.webp`)
await vier(`/zzzzzzzzzzzzzzzz/bild/${BILD_ID}-s.webp`) // fremder siteKey
// Artikel inaktiv
await aufbau({ produkte: [artikel('a1', { aktiv: false }, { bilder: [BILD_ID] })] })
await legeBildAb()
await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`)
// Artikel mit Bild, aber Datei fehlt (kein Sidecar)
await aufbau()
rmSync(path.join(uploadDir, SITE_KEY), { recursive: true, force: true })
await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`)
// abgeschalteter Shop liefert keine Bilder
await legeBildAb()
await aufbau({ feature: { state: 'disabled' } })
await legeBildAb()
await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`)
})
// ---------------------------------------------------------------- Grenzen
test('Grenze je IP: 120 je Minute, danach 429 mit Retry-After', async () => {
for (let i = 0; i < 120; i++) assert.equal(darfAnfrage(ip, JETZT), true)
const res = await api(`/${SITE_KEY}`)
assert.equal(res.status, 429)
assert.equal(kopfzeile(res, 'retry-after'), '60')
assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*')
assert.deepEqual(Object.keys(res.body), ['fehler'])
// andere IP ist nicht betroffen
assert.equal((await api(`/${SITE_KEY}`, { ip: '203.0.113.9' })).status, 200)
})
test('Grenze je Shop: 600 je Minute ueber alle IPs, unbekannte Shops zaehlen nicht', async () => {
for (let i = 0; i < 600; i++) assert.equal(zaehle(`shop:${SITE_KEY}`, 600, 60_000, JETZT), true)
const res = await api(`/${SITE_KEY}`)
assert.equal(res.status, 429)
assert.equal(kopfzeile(res, 'retry-after'), '60')
// unbekannter Shop: weiter aktiv:false statt 429 (kein Zaehler je unbekanntem Schluessel)
assert.deepEqual((await api('/zzzzzzzzzzzzzzzz')).body, { aktiv: false })
})
test('Fenster laeuft ab: nach einer Minute ist die Grenze weg', async () => {
for (let i = 0; i < 600; i++) zaehle(`shop:${SITE_KEY}`, 600, 60_000, JETZT)
assert.equal((await api(`/${SITE_KEY}`)).status, 429)
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 61_000))
assert.equal((await api(`/${SITE_KEY}`)).status, 200)
})
// ---------------------------------------------------------------- Rest
test('unbekannter Pfad: 404 ohne Rohtext', async () => {
const r1 = await api(`/${SITE_KEY}/gibtsnicht`)
assert.equal(r1.status, 404)
assert.deepEqual(r1.body, { fehler: { _: 'Nicht gefunden' } })
assert.equal(kopfzeile(r1, 'x-content-type-options'), 'nosniff')
})
test('eigener Parser: kaputter Rumpf 400, zu grosser Rumpf 413 (Router allein, ohne globalen Parser)', async () => {
const server = http.createServer(express().use('/api/public/shop/v1', router))
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const url = `http://127.0.0.1:${server.address().port}${BASIS}/${SITE_KEY}`
try {
const kaputt = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{kaputt' })
assert.equal(kaputt.status, 400)
assert.deepEqual(await kaputt.json(), { fehler: { _: 'Ungültige Anfrage.' } })
assert.equal(kaputt.headers.get('x-content-type-options'), 'nosniff')
const gross = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ x: 'a'.repeat(20_000) }) })
assert.equal(gross.status, 413)
assert.deepEqual(await gross.json(), { fehler: { _: 'Die Anfrage ist zu groß.' } })
} finally {
server.closeAllConnections()
await new Promise((resolve) => server.close(resolve))
}
})
test('Fehler der Datenhaltung: 503 ohne Rohtext', async () => {
// Datenhaltung nicht erreichbar: der Fehler (mit Adresse) gelangt nie nach aussen
const alt = config.appwrite.endpoint
config.appwrite.endpoint = 'http://127.0.0.1:1/v1'
try {
const res = await api(`/${SITE_KEY}`)
assert.equal(res.status, 503)
assert.deepEqual(res.body, { fehler: { _: 'Gerade nicht erreichbar' } })
assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*')
} finally {
config.appwrite.endpoint = alt
}
})
+541
View File
@@ -0,0 +1,541 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Oeffentliche Schnittstelle des Webshops, Kasse (F2): POST /pruefen, POST /bestellung, GET /bestellung/:id. Stripe ist der Nachbau,
// Mails landen in der Mailsenke; Preise kommen immer aus der Datenbank.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: { ...TERMINE_DATETIMES, ...SHOP_DATETIMES },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const K = await import('../server/services/shop/kontext.js')
const { normalisiereProdukt } = await import('../server/services/shop/produkte.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
const { formToken } = await import('../server/services/shop/token.js')
const { euro } = await import('../server/services/shop/geld.js')
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
const { default: router, setzeUhrFuerTests, resetPublicShopForTests } = await import('../server/routes/publicShop.js')
const app = await startTestApp({ '/api/public/shop/v1': router })
after(async () => {
setMailTransportForTests(null)
await Promise.all([app.close(), aw.close(), st.close()])
})
const BASIS = '/api/public/shop/v1'
const JETZT = new Date('2026-10-05T12:03:00.000Z')
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_shop'
const SITE_KEY = 'abcdefghijklmnop'
const ACCOUNT = 'acct_haendler1'
const O_LIVE = 'https://brot.example'
const O_PORTAL = 'https://portal.example'
const VORHER = { mode: config.shop.mode, pilot: [...config.shop.pilotEmails], basis: config.publicBaseUrl, formMin: config.shop.formMinSekunden, minAntwort: config.shop.minAntwortMs, from: config.smtp.from }
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
kleinunternehmer: true,
lieferzeit: '2–4 Werktage',
shopname: 'Brot & Mehr',
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' },
abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' },
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null },
zahlungsarten: ['card'],
}
const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' }
const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN)
const feature = (over = {}) => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true, ...over })
const shop = (over = {}) => ({
$id: FEATURE,
customerId: KUNDE,
projectId: PROJEKT,
siteKey: SITE_KEY,
anfrageStatus: 'angenommen',
stripeAccountId: ACCOUNT,
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }),
einstellungen: JSON.stringify(EINSTELLUNGEN),
rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }),
geoeffnet: true,
version: 1,
...over,
})
const artikel = (id, eingabe = {}) => {
const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...eingabe }, { einstellungen: { kleinunternehmer: true } })
assert.equal(r.fehler, undefined, JSON.stringify(r.fehler))
return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt) }
}
let sink
let ip
let n = 0
async function aufbau({ state = 'active', geoeffnet = true, produkte = [artikel('a1')] } = {}) {
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'termineOutbox', 'websiteProjects', 'customers']) {
for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id)
}
K.leereCache()
resetOutboxForTests()
resetTermineLimitsForTests()
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'brot.example' })
aw.seed('customers', { $id: KUNDE, name: 'Erika Muster', email: 'inhaber@brot.example' })
aw.seed('portalFeatures', feature({ state }))
aw.seed('shopConfig', shop({ geoeffnet }))
for (const p of produkte) aw.seed('shopProducts', p)
st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
}
const token = (alterMs = 10000) => formToken(SITE_KEY, new Date(JETZT.getTime() - alterMs))
const warenkorb = (over = {}) => ({ positionen: [{ produktId: 'a1', menge: 2 }], lieferart: 'versand', land: 'DE', ...over })
const kopf = (res, name) => res.headers.get(name)
const post = (pfad, body, { origin = O_LIVE, ip: adresse = ip, headers = {} } = {}) =>
send('POST', BASIS + pfad, { body, headers: { 'x-forwarded-for': adresse, ...(origin ? { origin } : {}), ...headers } })
const pruefen = (body = warenkorb(), opts) => post(`/${SITE_KEY}/pruefen`, body, opts)
const bestellen = (body = {}, opts) =>
post(`/${SITE_KEY}/bestellung`, { ...warenkorb(), rueckUrl: `${O_LIVE}/laden?x=1#top`, formToken: token(), ...body }, opts)
const status = (id, t, { ip: adresse = ip } = {}) =>
send('GET', `${BASIS}/${SITE_KEY}/bestellung/${id}${t === undefined ? '' : `?t=${encodeURIComponent(t)}`}`, { headers: { 'x-forwarded-for': adresse } })
const orders = () => [...aw.docs('shopOrders').values()]
const sessionCalls = () => st.calls.filter((c) => c.path === '/v1/checkout/sessions' && c.method === 'POST')
const tokenAusUrl = (url) => new URL(url).searchParams.get('wk_t')
beforeEach(async () => {
ip = `10.3.${Math.floor(++n / 250)}.${(n % 250) + 1}`
config.shop.mode = 'on'
config.shop.pilotEmails = []
config.shop.formMinSekunden = 3
config.shop.minAntwortMs = 0
config.publicBaseUrl = O_PORTAL
config.smtp.from = 'noreply@webklar.com'
setzeUhrFuerTests(() => JETZT)
sink = mailSink()
setMailTransportForTests(sink)
mock.method(console, 'warn', () => {})
st.calls.length = 0
await aufbau()
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
resetPublicShopForTests()
config.shop.mode = VORHER.mode
config.shop.pilotEmails = VORHER.pilot
config.shop.formMinSekunden = VORHER.formMin
config.shop.minAntwortMs = VORHER.minAntwort
config.publicBaseUrl = VORHER.basis
config.smtp.from = VORHER.from
})
const VERBOTEN = ['customerId', 'projectId', 'featureId', '$id', KUNDE, PROJEKT, FEATURE, ACCOUNT, 'inhaber@brot.example', 'stripeSessionId', 'tokenHash']
const ohneInternes = (body) => {
const s = JSON.stringify(body)
for (const v of VERBOTEN) assert.ok(!s.includes(v), `interner Wert in der Antwort: ${v}`)
}
// ---------------------------------------------------------------- pruefen
test('pruefen: Texte vom Server, Preisfelder des Browsers werden ignoriert, nichts wird angelegt', async () => {
const res = await pruefen(warenkorb({ positionen: [{ produktId: 'a1', menge: 2, preisCent: 1, einzelCent: 1, summeCent: 1, name: 'Hack' }], gesamtCent: 1 }))
assert.equal(res.status, 200)
assert.deepEqual(res.body, {
ok: true,
positionen: [{ produktId: 'a1', varianteId: null, name: 'Artikel a1', varianteText: '', menge: 2, einzelText: euro(1250), summeText: euro(2500), widerrufText: '' }],
texte: { zwischensumme: euro(2500), versand: euro(490), gesamt: euro(2990), steuerHinweis: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' },
lieferart: 'versand',
land: 'DE',
})
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
assert.equal(kopf(res, 'cache-control'), 'no-store')
ohneInternes(res.body)
assert.equal(orders().length, 0)
assert.equal(sessionCalls().length, 0)
})
test('pruefen: Abholung ohne Land, widerrufText aus dem Artikel', async () => {
await aufbau({ produkte: [artikel('a1', { widerruf: 'verderblich' })] })
const res = await pruefen(warenkorb({ lieferart: 'abholung', land: undefined }))
assert.equal(res.status, 200)
assert.equal(res.body.texte.versand, 'Abholung – kostenlos')
assert.equal(res.body.land, null)
assert.equal(res.body.positionen[0].widerrufText, 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware')
})
test('pruefen: geschlossener Shop (geoeffnet false oder disabled): 409 geschlossen', async () => {
for (const variante of [{ geoeffnet: false }, { state: 'disabled' }]) {
await aufbau(variante)
const res = await pruefen()
assert.equal(res.status, 409, JSON.stringify(variante))
assert.deepEqual(res.body, { fehler: { _: 'Der Shop ist gerade geschlossen.' }, code: 'geschlossen' })
}
})
test('pruefen: ausverkauft 409 mit produktId, zu große Menge, unbekannter Artikel, kaputter Rumpf', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 1 })] })
const aus = await pruefen(warenkorb({ positionen: [{ produktId: 'a1', menge: 2 }] }))
assert.equal(aus.status, 409)
assert.equal(aus.body.code, 'ausverkauft')
assert.equal(aus.body.produktId, 'a1')
assert.match(aus.body.fehler._, /nur noch 1/)
const menge = await pruefen(warenkorb({ positionen: [{ produktId: 'a1', menge: 1000 }] }))
assert.equal(menge.status, 400)
assert.equal(menge.body.code, 'menge')
const unbekannt = await pruefen(warenkorb({ positionen: [{ produktId: 'gibtsnicht', menge: 1 }] }))
assert.equal(unbekannt.status, 409)
assert.equal(unbekannt.body.code, 'nicht_verfuegbar')
const leer = await pruefen(warenkorb({ positionen: [] }))
assert.equal(leer.status, 400)
assert.equal(leer.body.code, 'leer')
const kaputt = await pruefen([1, 2])
assert.equal(kaputt.status, 400)
})
test('pruefen: offene Bestellungen reservieren den Bestand', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 3 })] })
assert.equal((await bestellen({ positionen: [{ produktId: 'a1', menge: 2 }] })).status, 200)
const res = await pruefen(warenkorb({ positionen: [{ produktId: 'a1', menge: 2 }] }))
assert.equal(res.status, 409)
assert.equal(res.body.code, 'ausverkauft')
})
test('pruefen: fremde Origin 403, unbekannter Shop aktiv:false, Grenze 60 je Minute', async () => {
const fremd = await pruefen(warenkorb(), { origin: 'https://fremd.example' })
assert.equal(fremd.status, 403)
assert.equal(kopf(fremd, 'access-control-allow-origin'), null)
const ohneOrigin = await pruefen(warenkorb(), { origin: null })
assert.equal(ohneOrigin.status, 403)
const unbekannt = await post('/zzzzzzzzzzzzzzzz/pruefen', warenkorb())
assert.equal(unbekannt.status, 200)
assert.deepEqual(unbekannt.body, { aktiv: false })
const eigene = `10.9.9.${(n % 250) + 1}`
for (let i = 0; i < 60; i += 1) assert.equal((await pruefen(warenkorb(), { ip: eigene })).status, 200, `Anfrage ${i + 1}`)
const zuViele = await pruefen(warenkorb(), { ip: eigene })
assert.equal(zuViele.status, 429)
assert.equal(kopf(zuViele, 'retry-after'), '60')
})
// ---------------------------------------------------------------- bestellung
test('bestellung: legt offene Bestellung an und liefert die Stripe-Adresse; Konto des Händlers, Preise aus der Datenbank', async () => {
const res = await bestellen({
positionen: [{ produktId: 'a1', menge: 2, preisCent: 1, einzelCent: 1, summeCent: 1 }],
gesamtCent: 1,
summeCent: 1,
versandCent: 0,
kontakt_url: '',
})
assert.equal(res.status, 200)
assert.deepEqual(Object.keys(res.body), ['url'])
assert.match(res.body.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/)
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
assert.equal(kopf(res, 'cache-control'), 'no-store')
ohneInternes(res.body)
const [order] = orders()
assert.equal(order.status, 'offen')
assert.equal(order.summeCent, 2 * 1250 + 490)
const [call] = sessionCalls()
assert.equal(call.account, ACCOUNT)
assert.equal(call.body['line_items[0][price_data][unit_amount]'], '1250')
assert.equal(call.body['line_items[0][quantity]'], '2')
assert.equal(call.body['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '490')
assert.match(call.body.success_url, /^https:\/\/brot\.example\/laden\?x=1&wk_bestellung=/)
assert.equal(call.body.cancel_url, 'https://brot.example/laden?x=1&wk_abbruch=1')
})
test('bestellung: Honigtopf oder zu schnell abgeschickt ⇒ scheinbarer Erfolg ohne Bestellung und ohne Stripe', async () => {
const honig = await bestellen({ kontakt_url: 'https://spam.example' })
assert.equal(honig.status, 200)
assert.deepEqual(honig.body, { url: 'https://brot.example/laden?x=1' })
const schnell = await bestellen({ formToken: token(500) })
assert.equal(schnell.status, 200)
assert.deepEqual(schnell.body, { url: 'https://brot.example/laden?x=1' })
// fremde Rueck-URL ⇒ Standardseite des Portals
const fremd = await bestellen({ kontakt_url: 'x', rueckUrl: 'https://boese.example/' })
assert.deepEqual(fremd.body, { url: `${O_PORTAL}/shop/${SITE_KEY}` })
assert.equal(orders().length, 0)
assert.equal(sessionCalls().length, 0)
})
test('bestellung: fehlendes, fremdes oder abgelaufenes Token ⇒ 400 Seite neu laden, nichts angelegt', async () => {
for (const formToken of [undefined, '', 'x.y', formToken_(SITE_KEY, 'anderersitekey1'), token(3 * 60 * 60 * 1000)]) {
const res = await bestellen({ formToken })
assert.equal(res.status, 400)
assert.deepEqual(res.body, { fehler: { _: 'Bitte laden Sie die Seite neu.' }, code: 'neu_laden' })
}
assert.equal(orders().length, 0)
})
function formToken_(_siteKey, fremder) {
return formToken(fremder, new Date(JETZT.getTime() - 10000))
}
test('bestellung: geschlossener Shop ⇒ 409 geschlossen; ausverkauft ⇒ 409 mit produktId; Stripe nicht aufgerufen', async () => {
await aufbau({ geoeffnet: false })
const zu = await bestellen()
assert.equal(zu.status, 409)
assert.deepEqual(zu.body, { fehler: { _: 'Der Shop ist gerade geschlossen.' }, code: 'geschlossen' })
await aufbau({ produkte: [artikel('a1', { bestand: 1 })] })
const aus = await bestellen({ positionen: [{ produktId: 'a1', menge: 5 }] })
assert.equal(aus.status, 409)
assert.equal(aus.body.code, 'ausverkauft')
assert.equal(aus.body.produktId, 'a1')
assert.equal(orders().length, 0)
assert.equal(sessionCalls().length, 0)
})
test('bestellung: Fehler von Stripe ⇒ 503 mit Text für Käufer, ohne Rohtext; Bestellung storniert', async () => {
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'geheimer Stripe-Rohtext' })
const res = await bestellen()
assert.equal(res.status, 503)
assert.equal(res.body.code, 'zahlung_nicht_moeglich')
assert.ok(!JSON.stringify(res.body).includes('geheimer'))
assert.equal(orders()[0].status, 'storniert')
})
test('bestellung: fremde Origin 403 und unbekannter Shop aktiv:false; kein Rumpf ⇒ 400', async () => {
const fremd = await bestellen({}, { origin: 'https://fremd.example' })
assert.equal(fremd.status, 403)
assert.equal(kopf(fremd, 'access-control-allow-origin'), null)
const unbekannt = await post('/zzzzzzzzzzzzzzzz/bestellung', { ...warenkorb(), formToken: token() })
assert.deepEqual(unbekannt.body, { aktiv: false })
const kaputt = await post(`/${SITE_KEY}/bestellung`, [1])
assert.equal(kaputt.status, 400)
assert.equal(orders().length, 0)
})
test('bestellung: Grenze 10 je IP und 10 Minuten (Retry-After 600), Grenze 300 je Shop und Stunde (3600) für alle Anfragen', async () => {
const eigene = `10.8.8.${(n % 250) + 1}`
for (let i = 0; i < 10; i += 1) assert.equal((await bestellen({ kontakt_url: 'x' }, { ip: eigene })).status, 200, `Versuch ${i + 1}`)
const zuViele = await bestellen({ kontakt_url: 'x' }, { ip: eigene })
assert.equal(zuViele.status, 429)
assert.equal(kopf(zuViele, 'retry-after'), '600')
resetTermineLimitsForTests()
let ok = 0
for (let i = 0; i < 300; i += 1) {
const res = await bestellen({ kontakt_url: 'x' }, { ip: `10.7.${Math.floor(i / 200)}.${(i % 200) + 1}` })
if (res.status === 200) ok += 1
}
assert.equal(ok, 300)
const shopGrenze = await bestellen({ kontakt_url: 'x' }, { ip: '10.7.9.9' })
assert.equal(shopGrenze.status, 429)
assert.equal(kopf(shopGrenze, 'retry-after'), '3600')
})
test('bestellung I4: höchstens 3 offene Bestellungen je IP und Shop in 30 Minuten; andere IPs, abgeschlossene und alte Bestellungen zählen nicht', async () => {
await aufbau({ produkte: [artikel('a1', {})] })
const eigene = `10.9.9.${(n % 250) + 1}`
for (let i = 0; i < 3; i += 1) assert.equal((await bestellen({}, { ip: eigene })).status, 200, `Bestellung ${i + 1}`)
assert.equal(orders().length, 3)
const vierte = await bestellen({}, { ip: eigene })
assert.equal(vierte.status, 429)
assert.equal(vierte.body.code, 'zu_viele_offen')
assert.equal(vierte.body.fehler._, 'Sie haben bereits offene Bestellungen. Bitte schließen Sie diese zuerst ab oder warten Sie 30 Minuten.')
assert.equal(kopf(vierte, 'retry-after'), '1800')
assert.equal(orders().length, 3, 'keine vierte Bestellung')
assert.equal(sessionCalls().length, 3, 'keine vierte Stripe-Sitzung')
// Der Hash steht in der Bestellung, die Adresse nicht
const json = orders()[0].bestellung
assert.match(JSON.parse(json).ipHash, /^[0-9a-f]{16}$/)
assert.ok(!JSON.stringify(orders()).includes(eigene), 'die IP-Adresse selbst wird nirgends gespeichert')
assert.equal(new Set(orders().map((o) => JSON.parse(o.bestellung).ipHash)).size, 1)
// Eine andere IP ist nicht betroffen
assert.equal((await bestellen({}, { ip: '10.9.8.1' })).status, 200)
// Eine storniert/abgeschlossene Bestellung zählt nicht mehr
const erste = orders()[0]
aw.seed('shopOrders', { ...erste, status: 'storniert' })
assert.equal((await bestellen({}, { ip: eigene })).status, 200)
assert.equal((await bestellen({}, { ip: eigene })).status, 429)
// Nach 30 Minuten sind die alten Bestellungen aus dem Fenster
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 31 * 60 * 1000))
const spaeter = await bestellen({ formToken: formToken(SITE_KEY, new Date(JETZT.getTime() + 20 * 60 * 1000)) }, { ip: eigene })
assert.equal(spaeter.status, 200)
})
test('bestellung I4: Honigtopf-Anfragen und Fehlversuche legen nichts an und zählen nicht als offene Bestellung', async () => {
const eigene = `10.9.7.${(n % 250) + 1}`
for (let i = 0; i < 5; i += 1) await bestellen({ kontakt_url: 'x' }, { ip: eigene })
await bestellen({ positionen: [] }, { ip: eigene })
assert.equal(orders().length, 0)
assert.equal((await bestellen({}, { ip: eigene })).status, 200)
})
test('bestellung: Mindestantwortzeit gilt auch bei Fehlern', async () => {
config.shop.minAntwortMs = 200
const t0 = performance.now()
const res = await bestellen({ formToken: '' })
assert.equal(res.status, 400)
assert.ok(performance.now() - t0 >= 190)
})
test('Preflight für pruefen und bestellung: erlaubte Origin ja, fremde nein', async () => {
for (const was of ['pruefen', 'bestellung']) {
const ok = await send('OPTIONS', `${BASIS}/${SITE_KEY}/${was}`, { headers: { origin: O_LIVE, 'x-forwarded-for': ip } })
assert.equal(ok.status, 204)
assert.equal(kopf(ok, 'access-control-allow-origin'), O_LIVE)
const fremd = await send('OPTIONS', `${BASIS}/${SITE_KEY}/${was}`, { headers: { origin: 'https://fremd.example', 'x-forwarded-for': ip } })
assert.equal(fremd.status, 403)
}
})
// ---------------------------------------------------------------- Status
async function offeneBestellung() {
const res = await bestellen()
assert.equal(res.status, 200)
const [order] = orders()
const [call] = sessionCalls()
return { orderId: order.$id, t: tokenAusUrl(call.body.success_url), sessionId: order.stripeSessionId, order }
}
test('Status: offene Bestellung mit gutem Token ⇒ offen, ohne Käuferdaten und ohne IDs', async () => {
const { orderId, t } = await offeneBestellung()
const res = await status(orderId, t)
assert.equal(res.status, 200)
assert.equal(res.body.status, 'offen')
assert.match(res.body.bestellnummer, /^26-[A-Z2-9]{6}$/)
assert.deepEqual(Object.keys(res.body).sort(), ['bestellnummer', 'email', 'gesamtText', 'lieferart', 'status'])
assert.equal(res.body.gesamtText, euro(2990))
assert.equal(res.body.lieferart, 'versand')
assert.equal(res.body.email, '')
assert.equal(kopf(res, 'access-control-allow-origin'), '*')
assert.equal(kopf(res, 'cache-control'), 'no-store')
assert.ok(!JSON.stringify(res.body).includes(orderId))
ohneInternes(res.body)
})
test('Status: schlechter oder fehlender Token, fremde Bestellung, kaputte ID ⇒ immer dieselbe 404', async () => {
const { orderId, t } = await offeneBestellung()
const erwartet = { fehler: { _: 'Bestellung nicht gefunden.' } }
const faelle = [
await status(orderId, 'falsch-falsch-falsch-falsch'),
await status(orderId, undefined),
await status(orderId, ''),
await status(orderId, 'x'),
await status('o_gibtesnicht', t),
await status('..%2f..%2fetc', t),
await status('a'.repeat(80), t),
]
for (const res of faelle) {
assert.equal(res.status, 404)
assert.deepEqual(res.body, erwartet)
}
// Bestellung eines anderen Shops mit richtigem Token
const anderer = 'f_p_projekt02_shop'
aw.seed('shopOrders', { ...orders()[0], $id: 'o_fremd01', shopId: anderer })
const fremd = await status('o_fremd01', t)
assert.equal(fremd.status, 404)
assert.deepEqual(fremd.body, erwartet)
// Hash als Token geht nicht
assert.equal((await status(orderId, orders().find((o) => o.$id === orderId).tokenHash)).status, 404)
})
test('Status: offen ⇒ bezahlt, sobald Stripe die Sitzung als bezahlt meldet; E-Mail verdeckt, kein Name und keine Adresse', async () => {
const { orderId, t, sessionId } = await offeneBestellung()
const vorher = await status(orderId, t)
assert.equal(vorher.body.status, 'offen')
st.completeSession(ACCOUNT, sessionId, {
payment_status: 'paid',
amount_total: 2990,
customer_details: { name: 'Max Mustermann', email: 'Max@Example.com', phone: '0170 1234567', address: { line1: 'Musterweg 1', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } },
shipping: { name: 'Max Mustermann', address: { line1: 'Musterweg 1', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } },
payment_intent: 'pi_test1',
})
const res = await status(orderId, t)
assert.equal(res.status, 200)
assert.equal(res.body.status, 'bezahlt')
assert.equal(res.body.email, 'M***@Example.com')
const s = JSON.stringify(res.body)
for (const verboten of ['Mustermann', 'Musterweg', 'Max@', '0170', 'pi_test1', sessionId]) assert.ok(!s.includes(verboten), verboten)
ohneInternes(res.body)
assert.equal(orders()[0].status, 'bezahlt')
// Zweiter Abruf: bleibt bezahlt, kein zweiter Abschluss (eine Mail an den Käufer)
await warteAufVersand()
const mailsVorher = sink.sent.length
const nochmal = await status(orderId, t)
assert.equal(nochmal.body.status, 'bezahlt')
await warteAufVersand()
assert.equal(sink.sent.length, mailsVorher)
})
test('Status I2: bezahlte Bestellung mit fehlendem Bestandsabzug wird beim nächsten Abruf nachgeholt', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 10 })] })
const { orderId, t, sessionId } = await offeneBestellung()
st.completeSession(ACCOUNT, sessionId, {
payment_status: 'paid',
amount_total: 2990,
customer_details: { name: 'Max Mustermann', email: 'max@example.com' },
shipping: { name: 'Max Mustermann', address: { line1: 'Musterweg 1', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } },
payment_intent: 'pi_test1',
})
aw.failNext({ collection: config.collections.shopProducts, method: 'PATCH', status: 500 })
const erster = await status(orderId, t)
assert.equal(erster.status, 200)
assert.equal(orders()[0].status, 'bezahlt')
assert.equal(JSON.parse(orders()[0].bestellung).bestandAbgezogenAt, null)
const zweiter = await status(orderId, t)
assert.equal(zweiter.body.status, 'bezahlt')
assert.ok(JSON.parse(orders()[0].bestellung).bestandAbgezogenAt)
await warteAufVersand()
const mails = sink.sent.length
await status(orderId, t)
await warteAufVersand()
assert.equal(sink.sent.length, mails, 'Mails nur einmal')
})
test('Status: nicht bezahlte Sitzung (z. B. SEPA unterwegs) bleibt offen; Stripe-Fehler beim Abgleich ⇒ trotzdem Antwort', async () => {
const { orderId, t, sessionId } = await offeneBestellung()
st.completeSession(ACCOUNT, sessionId, { payment_status: 'unpaid', amount_total: 2990 })
assert.equal((await status(orderId, t)).body.status, 'offen')
st.failNext({ method: 'GET', path: /checkout\/sessions\//, status: 400, message: 'kaputt' })
const res = await status(orderId, t)
assert.equal(res.status, 200)
assert.equal(res.body.status, 'offen')
})
test('Status: abgeschalteter Shop (disabled) zeigt Bestellungen weiter; Grenze 120 je Minute', async () => {
const { orderId, t } = await offeneBestellung()
aw.seed('portalFeatures', feature({ state: 'disabled' }))
K.leereCache()
assert.equal((await status(orderId, t)).status, 200)
const eigene = `10.6.6.${(n % 250) + 1}`
for (let i = 0; i < 120; i += 1) assert.equal((await status(orderId, 'x', { ip: eigene })).status, 404)
const zuViele = await status(orderId, t, { ip: eigene })
assert.equal(zuViele.status, 429)
assert.equal(kopf(zuViele, 'retry-after'), '60')
})
+409
View File
@@ -0,0 +1,409 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Oeffentliche Schnittstelle des Webshops, POST /:siteKey/widerruf (F3) samt Preflight: Herkunft, Honigtopf, Formular-Token,
// Fehlervertrag, Grenzen. Mails landen in der Mailsenke; nichts verlaesst den Prozess.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: { ...TERMINE_DATETIMES, ...SHOP_DATETIMES },
defaultLimit: 25,
})
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const K = await import('../server/services/shop/kontext.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { formToken } = await import('../server/services/shop/token.js')
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
const { default: router, setzeUhrFuerTests, resetPublicShopForTests } = await import('../server/routes/publicShop.js')
const app = await startTestApp({ '/api/public/shop/v1': router })
after(async () => {
setMailTransportForTests(null)
await Promise.all([app.close(), aw.close()])
})
const BASIS = '/api/public/shop/v1'
const JETZT = new Date('2026-10-05T12:03:00.000Z') // 14:03 Berlin (MESZ)
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_shop'
const SITE_KEY = 'abcdefghijklmnop'
const O_LIVE = 'https://brot.example'
const O_PORTAL = 'https://portal.example'
const VORHER = { mode: config.shop.mode, pilot: [...config.shop.pilotEmails], basis: config.publicBaseUrl, formMin: config.shop.formMinSekunden, minAntwort: config.shop.minAntwortMs, from: config.smtp.from }
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
kleinunternehmer: true,
lieferzeit: '2–4 Werktage',
shopname: 'Brot & Mehr',
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' },
}
const EINGABE = { name: 'Max Mustermann', email: 'Max@Example.com', bestellnummer: '26-k7qm2x', nachricht: 'Ich widerrufe.' }
const bestellung = () => ({
$id: 'o_bestellung01',
shopId: FEATURE,
customerId: KUNDE,
bestellnummer: '26-K7QM2X',
status: 'bezahlt',
kaeuferEmail: 'max@example.com',
summeCent: 1200,
erstelltAt: JETZT.toISOString(),
bezahltAt: JETZT.toISOString(),
bestellung: JSON.stringify({ positionen: [{ name: 'Brot', menge: 1, summeCent: 1200, widerruf: 'gesetzlich' }], verlauf: [{ at: JETZT.toISOString(), was: 'angelegt' }] }),
})
let sink
let ip
let n = 0
const feature = (over = {}) => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true, ...over })
const shop = () => ({
$id: FEATURE,
customerId: KUNDE,
projectId: PROJEKT,
siteKey: SITE_KEY,
anfrageStatus: 'angenommen',
einstellungen: JSON.stringify(EINSTELLUNGEN),
rechtstexte: JSON.stringify({ entwurf: {}, bestaetigt: null }),
geoeffnet: true,
version: 1,
})
async function aufbau({ state = 'active', mitBestellung = true } = {}) {
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'termineOutbox', 'websiteProjects', 'customers']) {
for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id)
}
K.leereCache()
resetOutboxForTests()
resetTermineLimitsForTests()
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'brot.example' })
aw.seed('customers', { $id: KUNDE, name: 'Erika Muster', email: 'inhaber@brot.example' })
aw.seed('portalFeatures', feature({ state }))
aw.seed('shopConfig', shop())
if (mitBestellung) aw.seed('shopOrders', bestellung())
}
const token = (alterMs = 10000) => formToken(SITE_KEY, new Date(JETZT.getTime() - alterMs))
const koerper = (over = {}) => ({ ...EINGABE, formToken: token(), ...over })
const post = (pfad, body, { origin = O_LIVE, ip: adresse = ip, headers = {} } = {}) =>
send('POST', BASIS + pfad, { body, headers: { 'x-forwarded-for': adresse, ...(origin ? { origin } : {}), ...headers } })
const widerruf = (body = koerper(), opts) => post(`/${SITE_KEY}/widerruf`, body, opts)
const kopf = (res, name) => res.headers.get(name)
const widerrufe = () => [...aw.docs('shopWiderrufe').values()]
const outbox = () => [...aw.docs('termineOutbox').values()]
beforeEach(async () => {
ip = `10.2.${Math.floor(++n / 250)}.${(n % 250) + 1}`
config.shop.mode = 'on'
config.shop.pilotEmails = []
config.shop.formMinSekunden = 3
config.shop.minAntwortMs = 0
config.publicBaseUrl = O_PORTAL
config.smtp.from = 'noreply@webklar.com'
setzeUhrFuerTests(() => JETZT)
sink = mailSink()
setMailTransportForTests(sink)
mock.method(console, 'warn', () => {})
await aufbau()
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
resetPublicShopForTests()
config.shop.mode = VORHER.mode
config.shop.pilotEmails = VORHER.pilot
config.shop.formMinSekunden = VORHER.formMin
config.shop.minAntwortMs = VORHER.minAntwort
config.publicBaseUrl = VORHER.basis
config.smtp.from = VORHER.from
})
const VERBOTEN = ['customerId', 'projectId', 'featureId', '$id', 'o_bestellung01', KUNDE, PROJEKT, FEATURE, 'inhaber@brot.example', 'max@example.com']
const ohneInternes = (body) => {
const s = JSON.stringify(body)
for (const v of VERBOTEN) assert.ok(!s.includes(v), `interner Wert in der Antwort: ${v}`)
}
test('Konfiguration: formMinSekunden und minAntwortMs sind Zahlen (Vorgaben 3 und 300)', () => {
assert.equal(typeof VORHER.formMin, 'number')
assert.equal(VORHER.formMin, 3)
assert.equal(VORHER.minAntwort, 300)
})
test('Erfolg mit passender Bestellung: { ok, eingang } ohne IDs, Widerruf gespeichert, G12 in der Warteschlange', async () => {
const res = await widerruf()
assert.equal(res.status, 200)
assert.deepEqual(res.body, { ok: true, eingang: '05.10.2026, 14:03 Uhr' })
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
assert.match(kopf(res, 'vary'), /Origin/)
assert.equal(kopf(res, 'cache-control'), 'no-store')
assert.equal(kopf(res, 'x-content-type-options'), 'nosniff')
ohneInternes(res.body)
await warteAufVersand()
const [doc] = widerrufe()
assert.equal(doc.orderId, 'o_bestellung01')
assert.equal(doc.shopId, FEATURE)
assert.equal(doc.email, 'max@example.com')
assert.equal(doc.bestellnummer, '26-K7QM2X')
const [g12] = outbox()
assert.equal(g12.art, 'G12')
assert.equal(g12.featureId, FEATURE)
const kaeufer = sink.sent.find((m) => m.to.some((t) => t.address === 'max@example.com'))
assert.ok(kaeufer, 'G12 versandt')
assert.ok(kaeufer.text.includes('05.10.2026, 14:03 Uhr'))
})
test('Erfolg ohne passende Bestellung: trotzdem erfasst und bestätigt', async () => {
await aufbau({ mitBestellung: false })
const res = await widerruf()
assert.equal(res.status, 200)
assert.equal(res.body.ok, true)
await warteAufVersand()
assert.equal(widerrufe().length, 1)
assert.equal(widerrufe()[0].orderId, '')
assert.equal(outbox()[0].art, 'G12')
})
test('Herkunft: Live-Domain des Betriebs, Portal und gleiche Herkunft sind erlaubt', async () => {
assert.equal((await widerruf(koerper(), { origin: O_PORTAL })).status, 200)
const gleich = await widerruf(koerper(), { origin: null, headers: { origin: app.url } })
assert.equal(gleich.status, 200)
assert.equal(kopf(gleich, 'access-control-allow-origin'), app.url)
})
test('fremde Origin: 403 ohne Eintrag und ohne erlaubende CORS-Kopfzeile', async () => {
for (const origin of ['https://fremd.example', 'https://brot.example.evil.example', 'https://brot.example/', 'HTTPS://BROT.EXAMPLE', 'null', 'ftp://brot.example']) {
const res = await widerruf(koerper(), { origin })
assert.equal(res.status, 403, origin)
assert.deepEqual(res.body, { fehler: { _: 'Nicht erlaubt' } })
assert.equal(kopf(res, 'access-control-allow-origin'), null)
}
assert.equal(widerrufe().length, 0)
})
test('fehlende Origin: 403', async () => {
const res = await widerruf(koerper(), { origin: null })
assert.equal(res.status, 403)
assert.deepEqual(res.body, { fehler: { _: 'Nicht erlaubt' } })
assert.equal(widerrufe().length, 0)
})
test('Preflight: erlaubte Origin bekommt 204 mit Methoden, Header, Max-Age und Vary: Origin (widerruf, pruefen, bestellung)', async () => {
for (const was of ['widerruf', 'pruefen', 'bestellung']) {
const res = await send('OPTIONS', `${BASIS}/${SITE_KEY}/${was}`, { headers: { 'x-forwarded-for': ip, origin: O_LIVE, 'access-control-request-method': 'POST' } })
assert.equal(res.status, 204, was)
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
assert.equal(kopf(res, 'access-control-allow-methods'), 'GET, POST, OPTIONS')
assert.equal(kopf(res, 'access-control-allow-headers'), 'Content-Type')
assert.equal(kopf(res, 'access-control-max-age'), '86400')
assert.match(kopf(res, 'vary'), /Origin/)
}
})
test('Preflight: fremde und fehlende Origin 403, unbekannter Shop und unbekannte Route ohne Verrat', async () => {
const fremd = await send('OPTIONS', `${BASIS}/${SITE_KEY}/widerruf`, { headers: { 'x-forwarded-for': ip, origin: 'https://fremd.example' } })
assert.equal(fremd.status, 403)
assert.equal(kopf(fremd, 'access-control-allow-origin'), null)
const ohne = await send('OPTIONS', `${BASIS}/${SITE_KEY}/widerruf`, { headers: { 'x-forwarded-for': ip } })
assert.equal(ohne.status, 403)
// unbekannter siteKey: nichts zu pruefen, nichts zu verraten
const unbekannt = await send('OPTIONS', `${BASIS}/zzzzzzzzzzzzzzzz/widerruf`, { headers: { 'x-forwarded-for': ip, origin: 'https://fremd.example' } })
assert.equal(unbekannt.status, 204)
assert.equal(kopf(unbekannt, 'access-control-allow-origin'), '*')
// andere Pfade sind keine POST-Routen
const anders = await send('OPTIONS', `${BASIS}/${SITE_KEY}/gibtsnicht`, { headers: { 'x-forwarded-for': ip, origin: O_LIVE } })
assert.equal(anders.status, 404)
})
test('Honigtopf kontakt_url gefüllt: gleiche Erfolgsform mit aktueller Zeit, nichts gespeichert, keine Mail', async () => {
for (const honig of ['http://spam.example', 'x', 0, false, ['a'], {}]) {
const res = await widerruf(koerper({ kontakt_url: honig }))
assert.equal(res.status, 200, JSON.stringify(honig))
assert.deepEqual(res.body, { ok: true, eingang: '05.10.2026, 14:03 Uhr' })
}
await warteAufVersand()
assert.equal(widerrufe().length, 0)
assert.equal(outbox().length, 0)
assert.equal(sink.sent.length, 0)
// leer oder Leerraum zählt nicht als gefüllt
assert.equal((await widerruf(koerper({ kontakt_url: ' ' }))).status, 200)
await warteAufVersand()
assert.equal(widerrufe().length, 1)
})
test('Honigtopf bei fremder Origin: trotzdem 403 (Herkunft vor Falle)', async () => {
assert.equal((await widerruf(koerper({ kontakt_url: 'x' }), { origin: 'https://fremd.example' })).status, 403)
})
test('Token zu schnell: Scheinerfolg, nichts gespeichert', async () => {
const res = await widerruf(koerper({ formToken: token(1000) }))
assert.equal(res.status, 200)
assert.deepEqual(res.body, { ok: true, eingang: '05.10.2026, 14:03 Uhr' })
await warteAufVersand()
assert.equal(widerrufe().length, 0)
assert.equal(sink.sent.length, 0)
})
test('Token abgelaufen, fehlend, fremd oder kaputt: 400 "Bitte laden Sie die Seite neu." mit code neu_laden', async () => {
const faelle = [
token(3 * 60 * 60 * 1000),
undefined,
'',
'abc.def',
formToken('anderer-key-12345', new Date(JETZT.getTime() - 10000)),
12345,
'x'.repeat(500),
]
for (const t of faelle) {
const body = koerper({ formToken: t })
if (t === undefined) delete body.formToken
const res = await widerruf(body)
assert.equal(res.status, 400, String(t).slice(0, 20))
assert.deepEqual(res.body, { fehler: { _: 'Bitte laden Sie die Seite neu.' }, code: 'neu_laden' })
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
}
assert.equal(widerrufe().length, 0)
})
test('kein Objekt als Rumpf: 400; zu groß: 413', async () => {
for (const body of [[], 'text', 5]) {
const res = await send('POST', `${BASIS}/${SITE_KEY}/widerruf`, { body: JSON.stringify(body), headers: { 'x-forwarded-for': ip, origin: O_LIVE, 'content-type': 'application/json' } })
assert.ok(res.status === 400, JSON.stringify(body))
}
const gross = await widerruf(koerper({ nachricht: 'x'.repeat(20000) }))
assert.equal(gross.status, 413)
})
test('Ohne Bestellnummer: 400 fehler.nachricht, solange die Erklärung fehlt; mit Erklärung 200', async () => {
const leer = await widerruf(koerper({ bestellnummer: '', nachricht: '' }))
assert.equal(leer.status, 400)
assert.deepEqual(leer.body.fehler, { nachricht: 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.' })
const ok = await widerruf(koerper({ bestellnummer: '', nachricht: 'Bestellt am 01.10., Tasse blau' }))
assert.equal(ok.status, 200)
assert.equal(ok.body.ok, true)
})
test('Prüfung: 400 mit fehler je Feld, nichts gespeichert', async () => {
const res = await widerruf(koerper({ name: 'M', email: 'keine-adresse', bestellnummer: '', nachricht: 'x'.repeat(2001) }))
assert.equal(res.status, 400)
assert.deepEqual(Object.keys(res.body.fehler).sort(), ['email', 'nachricht', 'name'])
for (const t of Object.values(res.body.fehler)) assert.match(t, /Bitte|darf/)
assert.equal(res.body.code, undefined)
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
assert.equal(widerrufe().length, 0)
})
test('Grenze je IP: nach 10 Versuchen je Stunde 429 mit Retry-After und Origin', async () => {
const eigene = '203.0.113.77'
for (let i = 0; i < 10; i++) assert.equal((await widerruf(koerper(), { ip: eigene })).status, 200, `Versuch ${i + 1}`)
const res = await widerruf(koerper(), { ip: eigene })
assert.equal(res.status, 429)
assert.equal(kopf(res, 'retry-after'), '3600')
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
assert.deepEqual(Object.keys(res.body), ['fehler'])
assert.match(res.body.fehler._, /Zu viele/)
})
test('Grenze je Shop (20 je Stunde): 429 für andere IPs', async () => {
for (let i = 0; i < 20; i++) assert.equal((await widerruf(koerper(), { ip: `198.51.100.${i + 1}` })).status, 200, `Versuch ${i + 1}`)
const res = await widerruf(koerper(), { ip: '198.51.100.99' })
assert.equal(res.status, 429)
assert.equal(res.body.fehler._.startsWith('Zu viele'), true)
})
test('Grenze für Anfragen je IP (darfAnfrage) kommt vor allem anderen: 429 mit Retry-After 60 und *', async () => {
const eigene = '203.0.113.88'
let letzte
for (let i = 0; i < 125; i++) {
letzte = await widerruf(koerper({ kontakt_url: 'x' }), { ip: eigene, origin: 'https://fremd.example' })
if (letzte.status === 429) break
}
assert.equal(letzte.status, 429)
assert.equal(kopf(letzte, 'retry-after'), '60')
assert.equal(kopf(letzte, 'access-control-allow-origin'), '*')
})
test('abgeschalteter Shop (disabled): Widerruf geht weiter', async () => {
await aufbau({ state: 'disabled' })
const res = await widerruf()
assert.equal(res.status, 200)
assert.equal(res.body.ok, true)
await warteAufVersand()
assert.equal(widerrufe().length, 1)
assert.equal(outbox()[0].art, 'G12')
})
test('unbekannter siteKey und andere Zustände: 200 { aktiv: false } ohne Origin-Prüfung', async () => {
const unbekannt = await post('/zzzzzzzzzzzzzzzz/widerruf', koerper(), { origin: 'https://fremd.example' })
assert.equal(unbekannt.status, 200)
assert.deepEqual(unbekannt.body, { aktiv: false })
assert.equal(kopf(unbekannt, 'access-control-allow-origin'), '*')
assert.equal(kopf(unbekannt, 'cache-control'), 'no-store')
const ungueltig = await post('/x/widerruf', koerper())
assert.deepEqual(ungueltig.body, { aktiv: false })
await aufbau({ state: 'pending' })
assert.deepEqual((await widerruf()).body, { aktiv: false })
assert.equal(widerrufe().length, 0)
})
test('M-2: der Widerruf geht auch bei SHOP_ENABLED=off und für Besitzer ausserhalb des Pilotkreises (active und disabled), Preflight ebenso', async () => {
for (const [modus, pilot] of [['off', []], ['pilot', ['jemand@anders.example']], ['pilot', []], ['on', []]]) {
config.shop.mode = modus
config.shop.pilotEmails = pilot
K.leereCache()
const vorher = widerrufe().length
const res = await widerruf()
assert.equal(res.body.ok, true, `${modus} ${pilot}`)
await warteAufVersand()
assert.equal(widerrufe().length, vorher + 1, `${modus}: Widerruf gespeichert`)
const preflight = await send('OPTIONS', `${BASIS}/${SITE_KEY}/widerruf`, { headers: { 'x-forwarded-for': ip, origin: O_LIVE, 'access-control-request-method': 'POST' } })
assert.equal(preflight.status, 204, `${modus}: Preflight`)
assert.equal(kopf(preflight, 'access-control-allow-origin'), O_LIVE)
}
// abgeschaltete Funktion: ebenfalls
config.shop.mode = 'off'
await aufbau({ state: 'disabled' })
assert.equal((await widerruf()).body.ok, true)
// aber die Bestellrouten bleiben bei off zu
const pruefen = await post(`/${SITE_KEY}/pruefen`, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'abholung' })
assert.deepEqual(pruefen.body, { aktiv: false })
// nicht active/disabled bleibt zu
await aufbau({ state: 'pending' })
assert.deepEqual((await widerruf()).body, { aktiv: false })
})
test('Mindestantwortzeit: auch Fehler und Fallen antworten nicht schneller als minAntwortMs', async () => {
config.shop.minAntwortMs = 250
for (const body of [koerper(), koerper({ kontakt_url: 'x' }), koerper({ formToken: 'kaputt' }), koerper({ name: '' })]) {
const t0 = performance.now()
await widerruf(body)
assert.ok(performance.now() - t0 >= 240, `zu schnell: ${Math.round(performance.now() - t0)} ms`)
}
})
test('Antworten enthalten nie IDs oder Adressen (Erfolg, Falle, Fehler)', async () => {
const antworten = [await widerruf(), await widerruf(koerper({ kontakt_url: 'x' })), await widerruf(koerper({ name: '' })), await widerruf(koerper({ formToken: 'x' }))]
for (const a of antworten) ohneInternes(a.body)
assert.deepEqual(Object.keys(antworten[0].body).sort(), ['eingang', 'ok'])
})
+87
View File
@@ -0,0 +1,87 @@
// Entscheidungen vom 04.10.2026: Projekt-Aktionen fuer alle Mitarbeiter, archivierte Projekte im
// Kundenportal ausgeblendet. (Private Repos: siehe projektAblauf.test.js, generate-Aufruf.)
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { startTestApp, rawGet, get, send, sessionCookie } = await import('./support/testApp.js')
const { default: projectsRouter } = await import('../server/routes/projects.js')
const { default: servicesRouter } = await import('../server/routes/admin/services.js')
const { default: customersRouter } = await import('../server/routes/admin/customers.js')
const { default: websiteProjectsRouter } = await import('../server/routes/admin/websiteProjects.js')
const { default: giteaRouter } = await import('../server/routes/admin/gitea.js')
const { default: funktionenRouter } = await import('../server/routes/funktionen.js')
const app = await startTestApp({
'/api/projects': projectsRouter,
'/api/admin/services': servicesRouter,
'/api/admin/customers': customersRouter,
'/api/admin/website-projects': websiteProjectsRouter,
'/api/admin/gitea': giteaRouter,
'/api/funktionen': funktionenRouter,
})
after(() => Promise.all([aw.close(), st.close(), app.close()]))
aw.seed('websiteProjects', { $id: 'p_aktiv', customerId: 'c1', projectName: 'Aktiv', subdomain: 'aktiv', status: 'deployed' })
aw.seed('websiteProjects', { $id: 'p_archiv', customerId: 'c1', projectName: 'Alt', subdomain: 'alt', status: 'archived', archived: true })
const kunde = { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) }
const bearer = (jwt) => ({ headers: { Authorization: `Bearer ${jwt}` } })
test('Kundenportal: archivierte Projekte stehen nicht in der Liste', async () => {
const { projects } = await get('/api/projects', kunde)
assert.deepEqual(projects.map((p) => p.id), ['p_aktiv'])
})
test('Kundenportal: archiviertes Projekt auch per Direktaufruf nicht erreichbar', async () => {
assert.equal((await rawGet('/api/projects/p_archiv/work', kunde)).status, 404)
assert.equal((await rawGet('/api/projects/p_aktiv/work', kunde)).status, 200)
})
test('Projekt-Routen: Mitarbeiter ohne Admin-Label kommen rein, Kunden-Konto nicht', async () => {
assert.equal((await rawGet('/api/admin/services', bearer('jwt-staff'))).status, 200)
assert.equal((await rawGet('/api/admin/services', bearer('jwt-admin'))).status, 200)
const kundeKonto = await rawGet('/api/admin/services', bearer('jwt-kunde'))
assert.equal(kundeKonto.status, 403)
assert.equal(kundeKonto.body.error, 'Nur für Mitarbeiter')
assert.equal((await rawGet('/api/admin/services')).status, 401)
})
test('Kunden-Verwaltung bleibt nur fuer Admins', async () => {
const r = await rawGet('/api/admin/customers', bearer('jwt-staff'))
assert.equal(r.status, 403)
assert.equal(r.body.error, 'Admin-Berechtigung erforderlich')
})
test('Funktionen: Kunde sieht archivierte Projekte nicht, die Mitarbeiter-Ansicht schon', async () => {
const { listCustomerFeatures } = await import('../server/services/siteFeatures.js')
assert.deepEqual((await listCustomerFeatures('c1', { docs: [] })).map((p) => p.projectId), ['p_aktiv'])
assert.deepEqual(
(await listCustomerFeatures('c1', { docs: [], includeArchived: true })).map((p) => p.projectId).sort(),
['p_aktiv', 'p_archiv']
)
})
test('Funktionen: archiviertes Projekt mit laufender Funktion bleibt fuer den Kunden sichtbar (Buchungen verwalten)', async () => {
const { listCustomerFeatures } = await import('../server/services/siteFeatures.js')
const docs = [{ customerId: 'c1', projectId: 'p_archiv', featureKey: 'termine', state: 'active' }]
assert.deepEqual((await listCustomerFeatures('c1', { docs })).map((p) => p.projectId).sort(), ['p_aktiv', 'p_archiv'])
const aus = [{ customerId: 'c1', projectId: 'p_archiv', featureKey: 'termine', state: 'off' }]
assert.deepEqual((await listCustomerFeatures('c1', { docs: aus })).map((p) => p.projectId), ['p_aktiv'])
})
test('Neu bestellen auf archiviertem Projekt geht nicht', async () => {
const r = await send('POST', '/api/funktionen/news/checkout', { ...kunde, body: { projectId: 'p_archiv', consent: true } })
assert.equal(r.status, 409)
assert.match(r.body.error, /archiviert/)
})
test('Vorlage bleibt Admin-Sache; README nur fuer bekannte Projekte', async () => {
const t = await send('PATCH', '/api/admin/website-projects/p_aktiv', { ...bearer('jwt-staff'), body: { projectType: 'template' } })
assert.equal(t.status, 403)
const fremd = await rawGet('/api/admin/gitea/repo-readme?repoFullName=JUSN/tickte-system', bearer('jwt-staff'))
assert.equal(fremd.status, 404)
})
+11
View File
@@ -126,6 +126,17 @@ test('Job: Mail ohne vorherigen Versuch wird nachgeholt (createdByGitea, 0 Versu
assert.equal(sink.sent.length, 1)
})
test('Job: vom System-Konto angelegtes Projekt ohne Art wird nicht abgefragt und nicht gemailt', async () => {
freshSink()
gitea.addRepo({ full_name: 'WEBklar/aus-womS', created_at: ago(1) })
aw.insert('websiteProjects', { repoFullName: 'WEBklar/aus-womS', projectName: 'aus WOMS', createdByGitea: 'webklar-system', assignMailAttempts: 0 })
const before = gitea.calls.filter((c) => c.path === '/api/v1/repos/WEBklar/aus-womS').length
const c = await runRepoAssignJob({ now: NOW })
assert.equal(c.retries, 0)
assert.equal(sink.sent.length, 0)
assert.equal(gitea.calls.filter((x) => x.path === '/api/v1/repos/WEBklar/aus-womS').length, before)
})
test('Job: neues Nutzer-Repo → Mail, Org-Repo → keine Mail', async () => {
freshSink()
gitea.addRepo({ full_name: 'knso/jobneu', created_at: ago(1) })
+346
View File
@@ -17,12 +17,19 @@ const aw = await startAppwriteMock({
takeoverCredits: schemaKeys('takeoverCredits'),
newsPosts: schemaKeys('newsPosts'),
portalFeatures: schemaKeys('portalFeatures'),
featureConsents: schemaKeys('featureConsents'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
},
})
const st = await startFakeStripe()
const uploadDir = mkdtempSync(path.join(os.tmpdir(), 'loeschfristen-'))
process.env.TAKEOVER_UPLOAD_DIR = uploadDir
process.env.NEWS_UPLOAD_DIR = path.join(uploadDir, 'news')
process.env.SHOP_UPLOAD_DIR = path.join(uploadDir, 'shop')
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { findDueDeletions, applyDeletions, formatReport, CONSENT_METADATA_KEYS } = await import('../server/services/retention.js')
const { run } = await import('../scripts/loeschfristen.mjs')
@@ -327,3 +334,342 @@ test('retention: Analyse-Dokument schaltet auch den Sammeldienst ab', async () =
analyseDeps.client = ok
}
})
test('retention_loescht_featureConsents: Zustimmungsnachweise fälliger Kunden werden geplant, gelöscht und berichtet; andere bleiben', async () => {
const consent = (id, customerId, projectId, featureKey) => aw.seed('featureConsents', {
$id: id, customerId, projectId, featureKey, stripeSessionId: `cs_${id}`, acceptedAt: '2022-02-01T10:00:00.000+00:00',
fassungen: '{}', texte: '[]', textHash: 'x', ip: '10.0.0.1', userAgent: 'Browser', konto: 'k@example.com',
})
// fälliger Kunde: Abo endete 2022, zwei Funktionsbestellungen (Terminplaner und News) in zwei Projekten, sonst nichts
sub('fc_sub', 'fc', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' })
stripeSub('fc_sub', 'fc')
consent('fc_c1', 'fc', 'fcp1', 'termine')
consent('fc_c2', 'fc', 'fcp2', 'news')
// nicht fälliger Kunde (laufendes Abo) mit Nachweis, und ein Kunde ohne Abo (nur News) mit Nachweis: beide bleiben wie bisher unberührt
sub('fc2_sub', 'fc2', { status: 'active' })
consent('fc2_c1', 'fc2', 'fc2p1', 'termine')
consent('fc3_c1', 'fc3', 'fc3p1', 'news')
aw.seed('newsPosts', { $id: 'fc3_post', customerId: 'fc3', projectId: 'fc3p1', type: 'note', imageId: null, title: 'Hi', text: 'Hallo' })
// die Abfrage nach customerId hat im Schema (scripts/feature-setup.mjs) einen Index
const { FEATURE_CONSENT_INDEXES } = await import('../scripts/feature-setup.mjs')
assert.ok(FEATURE_CONSENT_INDEXES.some((i) => i.attributes.length === 1 && i.attributes[0] === 'customerId'), 'Index auf featureConsents.customerId fehlt')
const plan = await findDueDeletions(JAN_2026)
const eintrag = plan.news.find((n) => n.customerId === 'fc')
assert.ok(eintrag, 'ein fälliger Kunde nur mit Nachweisen (ohne Beiträge und Funktionen) steht im Plan')
assert.deepEqual(eintrag.consentIds.sort(), ['fc_c1', 'fc_c2'])
assert.deepEqual(eintrag.posts, [])
assert.deepEqual(eintrag.featureIds, [])
assert.ok(!plan.news.some((n) => n.customerId === 'fc2' || n.customerId === 'fc3'), 'nicht fälliger Kunde und Kunde ohne Abo bleiben außerhalb')
assert.match(formatReport(plan), /Kunde fc: 0 News-Beitrag\/-Beiträge mit 0 Foto\(s\) zu löschen, 0 Funktion\(en\) abzuschalten, 2 Zustimmungsnachweis\(e\) zu löschen/)
// Probelauf löscht nichts
const probe = await run({ mode: '', now: JAN_2026, log: () => {} })
assert.equal(probe.applied, false)
assert.ok(aw.docs('featureConsents').has('fc_c1') && aw.docs('featureConsents').has('fc_c2'))
const result = await applyDeletions({ ...plan, customers: plan.customers.filter((c) => c.customerId === 'fc'), news: plan.news.filter((n) => n.customerId === 'fc') })
assert.deepEqual(result.failed, [])
assert.equal(result.deleted.featureConsents, 2)
assert.ok(!aw.docs('featureConsents').has('fc_c1') && !aw.docs('featureConsents').has('fc_c2'))
assert.ok(aw.docs('featureConsents').has('fc2_c1') && aw.docs('featureConsents').has('fc3_c1'), 'Nachweise anderer Kunden bleiben')
assert.match(formatReport(plan, result), /Zustimmungsnachweise zu Funktionsbestellungen: 2 gelöscht\./)
// ein zweiter Lauf findet nichts mehr für diesen Kunden
const danach = await findDueDeletions(JAN_2026)
assert.ok(!danach.news.some((n) => n.customerId === 'fc'))
})
test('retention_loescht_featureConsents: Ein Fehler beim Löschen eines Nachweises stoppt den Rest nicht und landet in failed; ältere Pläne ohne consentIds laufen weiter', async () => {
const consent = (id, customerId) => aw.seed('featureConsents', {
$id: id, customerId, projectId: 'fcxp', featureKey: 'termine', stripeSessionId: '', acceptedAt: '2021-02-01T10:00:00.000+00:00',
fassungen: '{}', texte: '[]', textHash: 'x', ip: '', userAgent: '', konto: 'Mitarbeiter: Test',
})
consent('fcx_c1', 'fcx')
consent('fcx_c2', 'fcx')
aw.failNext({ collection: 'featureConsents', method: 'DELETE', status: 500, message: 'kaputt' })
const result = await applyDeletions({ customers: [], takeover: [], news: [{ customerId: 'fcx', posts: [], featureIds: [], consentIds: ['fcx_c1', 'fcx_c2'] }] })
assert.equal(result.failed.length, 1)
assert.equal(result.failed[0].what, 'featureConsents')
assert.equal(result.failed[0].id, 'fcx_c1')
assert.match(result.failed[0].reason, /kaputt/)
assert.equal(result.deleted.featureConsents, 1)
assert.ok(aw.docs('featureConsents').has('fcx_c1') && !aw.docs('featureConsents').has('fcx_c2'))
// ein zweiter Aufruf mit demselben Plan holt den Rest nach (in einem Lauf mit Abo-Dokument plant ihn findDueDeletions erneut, siehe die Tests
// „Fehlschlag … hält den Kunden“); ein Eintrag, der inzwischen weg ist (404), zählt nicht
const nochmal = await applyDeletions({ customers: [], takeover: [], news: [{ customerId: 'fcx', posts: [], featureIds: [], consentIds: ['fcx_c1', 'fcx_c2'] }] })
assert.deepEqual(nochmal.failed, [])
assert.equal(nochmal.deleted.featureConsents, 1)
// Pläne ohne consentIds (ältere Form) bleiben gültig
const alt = await applyDeletions({ customers: [], takeover: [], news: [{ customerId: 'fcx', posts: [], featureIds: [] }] })
assert.equal(alt.deleted.featureConsents, 0)
assert.deepEqual(alt.failed, [])
})
// ------------------------------------------------------- C1: IP, Browser, Text und Zeit der Bestellung an portalFeatures
const KLICK = { consentIp: '203.0.113.7', consentUserAgent: 'Mozilla/5.0 Test', consentText: 'Ich stimme zu.', consentAt: '2022-02-01T10:00:00.000Z' }
const LEER = { consentIp: '', consentUserAgent: '', consentText: '', consentAt: null }
const feld = (id) => {
const d = aw.docs('portalFeatures').get(id)
return { consentIp: d.consentIp, consentUserAgent: d.consentUserAgent, consentText: d.consentText, consentAt: d.consentAt }
}
test('C1 retention: IP, Browser, Text und Zeit werden auf ALLEN Funktionsdokumenten eines fälligen Kunden geleert, auch auf abgeschalteten; andere Kunden und Felder bleiben', async () => {
sub('cf_sub', 'cf', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' })
stripeSub('cf_sub', 'cf')
aw.seed('portalFeatures', { $id: 'cf_aktiv', customerId: 'cf', projectId: 'cfp1', featureKey: 'termine', state: 'active', enabled: true, siteKey: 'k1', ...KLICK })
aw.seed('portalFeatures', { $id: 'cf_aus', customerId: 'cf', projectId: 'cfp2', featureKey: 'news', state: 'disabled', enabled: false, siteKey: 'k2', disabledAt: '2022-03-15T10:00:00.000Z', ...KLICK })
aw.seed('portalFeatures', { $id: 'cf_leer', customerId: 'cf', projectId: 'cfp3', featureKey: 'news', state: 'disabled', enabled: false, siteKey: 'k3', consentIp: '', consentUserAgent: '', consentText: '', consentAt: null })
sub('cf2_sub', 'cf2', { status: 'active' })
aw.seed('portalFeatures', { $id: 'cf2_aktiv', customerId: 'cf2', projectId: 'cf2p', featureKey: 'termine', state: 'active', enabled: true, siteKey: 'k4', ...KLICK })
const plan = await findDueDeletions(JAN_2026)
const eintrag = plan.news.find((n) => n.customerId === 'cf')
assert.ok(eintrag, 'ein fälliger Kunde mit gespeicherten Bestellangaben steht im Plan')
assert.deepEqual(eintrag.featureIds, ['cf_aktiv'], 'abschalten: nur das noch nicht abgeschaltete')
assert.deepEqual([...eintrag.featureFieldIds].sort(), ['cf_aktiv', 'cf_aus'], 'leeren: alle mit Angaben, auch das abgeschaltete; das leere Dokument nicht')
assert.ok(!plan.news.some((n) => n.customerId === 'cf2'))
assert.match(formatReport(plan), /Kunde cf: .*2 Funktionsdokument\(e\) mit Zustimmungsangaben zu leeren/)
// Probelauf ändert nichts
await run({ mode: '', now: JAN_2026, log: () => {} })
assert.deepEqual(feld('cf_aus'), { ...KLICK })
const result = await applyDeletions(planVon(plan, 'cf'))
assert.deepEqual(result.failed, [])
assert.equal(result.deleted.featureFields, 2)
assert.deepEqual(feld('cf_aktiv'), LEER)
assert.deepEqual(feld('cf_aus'), LEER)
assert.equal(aw.docs('portalFeatures').get('cf_aktiv').state, 'disabled', 'abgeschaltet wie bisher')
assert.equal(aw.docs('portalFeatures').get('cf_aus').siteKey, 'k2', 'übrige Felder unberührt')
assert.deepEqual(feld('cf2_aktiv'), { ...KLICK }, 'laufender Kunde bleibt')
assert.match(formatReport(plan, result), /Zustimmungsangaben an Funktionsdokumenten \(IP, Browser, Text, Zeit\): 2 geleert\./)
// ein zweiter Lauf findet nichts mehr für diesen Kunden
assert.ok(!(await findDueDeletions(JAN_2026)).news.some((n) => n.customerId === 'cf'))
})
test('C1 retention: scheitert das Leeren, hält es den Kunden (Abo bleibt), der nächste Lauf plant es erneut; ältere Pläne ohne featureFieldIds laufen weiter', async () => {
sub('cg_sub', 'cg', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' })
stripeSub('cg_sub', 'cg')
aw.seed('portalFeatures', { $id: 'cg_aus', customerId: 'cg', projectId: 'cgp', featureKey: 'news', state: 'disabled', enabled: false, siteKey: 'k5', ...KLICK })
const plan = await findDueDeletions(JAN_2026)
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
const result = await applyDeletions(planVon(plan, 'cg'))
assert.deepEqual(result.failed.map((f) => [f.what, f.id]), [['portalFeatures', 'cg_aus']])
assert.deepEqual(result.held, ['cg'])
assert.ok(aw.docs('subscriptions').has('cg_sub'), 'das Abo-Dokument bleibt, sonst fände der nächste Lauf den Kunden nicht')
assert.deepEqual(feld('cg_aus'), { ...KLICK })
const nochmal = await findDueDeletions(JAN_2026)
assert.deepEqual(nochmal.news.find((n) => n.customerId === 'cg').featureFieldIds, ['cg_aus'])
const zweiter = await applyDeletions(planVon(nochmal, 'cg'))
assert.deepEqual(zweiter.failed, [])
assert.deepEqual(feld('cg_aus'), LEER)
assert.ok(!aw.docs('subscriptions').has('cg_sub'))
const alt = await applyDeletions({ customers: [], takeover: [], news: [{ customerId: 'cgx', posts: [], featureIds: [], consentIds: [] }] })
assert.equal(alt.deleted.featureFields, 0)
assert.deepEqual(alt.failed, [])
})
// ---------------------------------------------------------------- Reihenfolge: abhängige Daten vor dem Abo (Fix Runde 1, R119)
/** Kunde mit beendetem Abo (2022), einem Vertragsereignis und einem Zustimmungsnachweis, ohne Beiträge und Funktionen. */
function seedFaelligMitNachweis(id) {
sub(`${id}_sub`, id, { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' })
event(`${id}_ev`, id, `${id}_sub`)
stripeSub(`${id}_sub`, id)
aw.seed('featureConsents', {
$id: `${id}_c1`, customerId: id, projectId: `${id}p`, featureKey: 'termine', stripeSessionId: `cs_${id}`, acceptedAt: '2022-02-01T10:00:00.000+00:00',
fassungen: '{}', texte: '[]', textHash: 'x', ip: '10.0.0.1', userAgent: 'Browser', konto: 'k@example.com',
})
}
const planVon = (plan, customerId) => ({
...plan,
customers: plan.customers.filter((c) => c.customerId === customerId),
news: plan.news.filter((n) => n.customerId === customerId),
takeover: [],
})
test('Fehlschlag beim Löschen eines Zustimmungsnachweises hält den Kunden: Abo und Vertragsereignisse bleiben, der nächste Lauf plant den Nachweis erneut', async () => {
seedFaelligMitNachweis('ro')
const plan = await findDueDeletions(JAN_2026)
assert.ok(dueIds(plan).includes('ro'))
aw.failNext({ collection: 'featureConsents', method: 'DELETE', status: 500, message: 'kaputt' })
const result = await applyDeletions(planVon(plan, 'ro'))
assert.equal(result.failed.length, 1)
assert.deepEqual([result.failed[0].what, result.failed[0].id], ['featureConsents', 'ro_c1'])
assert.match(result.failed[0].reason, /kaputt/)
assert.deepEqual(result.held, ['ro'], 'der Kunde wird als gehalten gemeldet')
assert.equal(result.deleted.featureConsents, 0)
assert.equal(result.deleted.subscriptions, 0)
assert.equal(result.deleted.contractEvents, 0)
assert.ok(aw.docs('subscriptions').has('ro_sub') && aw.docs('contractEvents').has('ro_ev') && aw.docs('featureConsents').has('ro_c1'), 'nichts davon ist weg')
assert.match(formatReport(planVon(plan, 'ro'), result), /Abo-Daten bleiben[\s\S]*ro/)
// der nächste Lauf findet den Kunden und den Nachweis wieder (das Abo-Dokument ist ja noch da) ...
const zweiter = await findDueDeletions(JAN_2026)
assert.ok(dueIds(zweiter).includes('ro'))
assert.deepEqual(zweiter.news.find((n) => n.customerId === 'ro').consentIds, ['ro_c1'])
// ... und löscht jetzt alles
const fertig = await applyDeletions(planVon(zweiter, 'ro'))
assert.deepEqual(fertig.failed, [])
assert.deepEqual(fertig.held, [])
assert.equal(fertig.deleted.featureConsents, 1)
assert.equal(fertig.deleted.subscriptions, 1)
assert.ok(!aw.docs('subscriptions').has('ro_sub') && !aw.docs('contractEvents').has('ro_ev') && !aw.docs('featureConsents').has('ro_c1'))
assert.ok(!dueIds(await findDueDeletions(JAN_2026)).includes('ro'))
})
test('Fehlschlag beim Löschen eines News-Beitrags hält den Kunden ebenso (dasselbe Muster wie bei den Nachweisen)', async () => {
sub('rp_sub', 'rp', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' })
event('rp_ev', 'rp', 'rp_sub')
stripeSub('rp_sub', 'rp')
aw.seed('newsPosts', { $id: 'rp_post', customerId: 'rp', projectId: 'rpp', type: 'note', imageId: null, title: 'Hi', text: 'Hallo' })
const plan = await findDueDeletions(JAN_2026)
assert.deepEqual(plan.news.find((n) => n.customerId === 'rp').posts.map((p) => p.id), ['rp_post'])
aw.failNext({ collection: 'newsPosts', method: 'DELETE', status: 500, message: 'kaputt' })
const result = await applyDeletions(planVon(plan, 'rp'))
assert.deepEqual(result.failed.map((f) => [f.what, f.id]), [['newsPosts', 'rp_post']])
assert.deepEqual(result.held, ['rp'])
assert.ok(aw.docs('subscriptions').has('rp_sub') && aw.docs('contractEvents').has('rp_ev') && aw.docs('newsPosts').has('rp_post'))
const zweiter = await findDueDeletions(JAN_2026)
assert.ok(dueIds(zweiter).includes('rp'))
assert.deepEqual(zweiter.news.find((n) => n.customerId === 'rp').posts.map((p) => p.id), ['rp_post'])
const fertig = await applyDeletions(planVon(zweiter, 'rp'))
assert.deepEqual(fertig.failed, [])
assert.ok(!aw.docs('subscriptions').has('rp_sub') && !aw.docs('newsPosts').has('rp_post'))
})
test('Fehlschlag beim Löschen eines Vertragsereignisses hält das Abo ebenfalls (sonst bliebe das Ereignis als Waise liegen)', async () => {
sub('rq_sub', 'rq', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' })
event('rq_ev', 'rq', 'rq_sub')
stripeSub('rq_sub', 'rq')
const plan = await findDueDeletions(JAN_2026)
aw.failNext({ collection: 'contractEvents', method: 'DELETE', status: 500, message: 'kaputt' })
const result = await applyDeletions(planVon(plan, 'rq'))
assert.deepEqual(result.failed.map((f) => [f.what, f.id]), [['contractEvents', 'rq_ev']])
assert.deepEqual(result.held, ['rq'])
assert.ok(aw.docs('subscriptions').has('rq_sub'), 'das Abo-Dokument bleibt, damit der nächste Lauf den Kunden wieder findet')
assert.ok(dueIds(await findDueDeletions(JAN_2026)).includes('rq'))
const fertig = await applyDeletions(planVon(await findDueDeletions(JAN_2026), 'rq'))
assert.deepEqual(fertig.failed, [])
assert.ok(!aw.docs('subscriptions').has('rq_sub') && !aw.docs('contractEvents').has('rq_ev'))
})
test('Reihenfolge: Beiträge, Funktionen und Nachweise gehen vor den Vertragsereignissen und Abo-Dokumenten', async () => {
seedFaelligMitNachweis('rr')
const plan = await findDueDeletions(JAN_2026)
const reihenfolge = []
const echt = globalThis.fetch
globalThis.fetch = async (url, optionen) => {
if (optionen?.method === 'DELETE') reihenfolge.push(String(url).match(/collections\/([^/]+)\/documents/)?.[1])
return echt(url, optionen)
}
try {
await applyDeletions(planVon(plan, 'rr'))
} finally {
globalThis.fetch = echt
}
assert.deepEqual(reihenfolge, ['featureConsents', 'contractEvents', 'subscriptions'])
})
test('Nachweis-Liste an der Grenze: findDueDeletions bricht ab, bevor etwas geplant wird (fail-closed)', async () => {
sub('rs_sub', 'rs', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' })
stripeSub('rs_sub', 'rs')
const grenze = Math.max(...['subscriptions', 'contractEvents', 'newsPosts', 'portalFeatures', 'takeoverCredits'].map((c) => aw.docs(c).size)) + 1
for (let i = 0; i < grenze; i += 1) {
aw.seed('featureConsents', {
$id: `rs_c${i}`, customerId: 'rs', projectId: 'rsp', featureKey: 'termine', stripeSessionId: '', acceptedAt: '2022-02-01T10:00:00.000+00:00',
fassungen: '{}', texte: '[]', textHash: 'x', ip: '', userAgent: '', konto: 'k',
})
}
await assert.rejects(findDueDeletions(JAN_2026, { limit: grenze }), /Liste abgeschnitten, Paginierung nötig \(featureConsents/)
assert.equal([...aw.docs('featureConsents').values()].filter((c) => c.customerId === 'rs').length, grenze, 'nichts gelöscht')
})
// ------------------------------------------------------------------------------------------------ Webshop (J3)
const SHOP_KEY = 'abcdefghijklmnop'
function seedFaelligMitShop(kunde, siteKey = SHOP_KEY) {
sub(`${kunde}_sub`, kunde, { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' })
event(`${kunde}_ev`, kunde, `${kunde}_sub`)
stripeSub(`${kunde}_sub`, kunde)
const shopId = `f_${kunde}_shop`
aw.seed('shopConfig', { $id: shopId, customerId: kunde, projectId: `${kunde}p`, siteKey, geoeffnet: false })
aw.seed('shopProducts', { $id: `${kunde}_prod`, shopId, customerId: kunde, aktiv: true, sort: 1, name: 'Tasse', produkt: '{}' })
aw.seed('shopOrders', { $id: `${kunde}_ord1`, shopId, customerId: kunde, bestellnummer: '26-A', status: 'versendet', kaeuferEmail: 'k@example.com', bestellung: '{}' })
aw.seed('shopOrders', { $id: `${kunde}_ord2`, shopId, customerId: kunde, bestellnummer: '26-B', status: 'storniert', kaeuferEmail: 'k@example.com', bestellung: '{}' })
aw.seed('shopWiderrufe', { $id: `${kunde}_wid`, shopId, customerId: kunde, bestellnummer: '26-A', name: 'K', email: 'k@example.com', nachricht: 'x' })
aw.seed('shopRechtstexteArchiv', { $id: `${kunde}_rt`, shopId, customerId: kunde, hash: 'a'.repeat(64), texte: '{}', bestaetigtVon: 'K' })
const ordner = path.join(uploadDir, 'shop', siteKey)
mkdirSync(ordner, { recursive: true })
writeFileSync(path.join(ordner, 'bild-l.webp'), 'x')
return shopId
}
test('Webshop: fällige Kunden verlieren auch Bestellungen, Artikel, Widerrufe, Bilder und shopConfig; andere Kunden bleiben; Bericht nennt es', async () => {
seedFaelligMitShop('sh1')
seedFaelligMitShop('sh2', 'bcdefghijklmnopq')
// nicht fälliger Kunde (laufendes Abo) mit Shop
sub('sh3_sub', 'sh3', { status: 'active' })
aw.seed('shopConfig', { $id: 'f_sh3_shop', customerId: 'sh3', projectId: 'sh3p', siteKey: 'cdefghijklmnopqr' })
aw.seed('shopOrders', { $id: 'sh3_ord', shopId: 'f_sh3_shop', customerId: 'sh3', bestellnummer: '26-C', status: 'versendet', bestellung: '{}' })
const plan = await findDueDeletions(JAN_2026)
const eintrag = plan.news.find((n) => n.customerId === 'sh1')
assert.deepEqual(eintrag.shops, [{ id: 'f_sh1_shop', siteKey: SHOP_KEY }])
assert.ok(!plan.news.some((n) => n.customerId === 'sh3'), 'nicht fälliger Kunde bleibt außerhalb')
assert.match(formatReport(plan), /Kunde sh1: .*1 Webshop\(s\) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen/)
// ältere Einträge ohne Shop tragen den Schlüssel nicht (Plan bleibt für andere Daten gleich)
assert.ok(plan.news.filter((n) => !n.shops).every((n) => !('shops' in n)))
const probe = await run({ mode: '', now: JAN_2026, log: () => {} })
assert.equal(probe.applied, false)
assert.ok(aw.docs('shopOrders').has('sh1_ord1'))
const reihenfolge = []
const echt = globalThis.fetch
globalThis.fetch = async (url, optionen) => {
if (optionen?.method === 'DELETE') reihenfolge.push(String(url).match(/collections\/([^/]+)\/documents/)?.[1])
return echt(url, optionen)
}
let result
try {
result = await applyDeletions({ ...plan, customers: plan.customers.filter((c) => c.customerId === 'sh1'), news: plan.news.filter((n) => n.customerId === 'sh1'), takeover: [] })
} finally {
globalThis.fetch = echt
}
assert.deepEqual(result.failed, [])
assert.deepEqual(result.held, [])
assert.equal(result.deleted.shops, 1)
assert.equal(result.deleted.shopDocs, 5)
for (const [col, ids] of [['shopOrders', ['sh1_ord1', 'sh1_ord2']], ['shopProducts', ['sh1_prod']], ['shopWiderrufe', ['sh1_wid']], ['shopRechtstexteArchiv', ['sh1_rt']], ['shopConfig', ['f_sh1_shop']]]) {
for (const docId of ids) assert.ok(!aw.docs(col).has(docId), `${col} ${docId} weg`)
}
assert.ok(!existsSync(path.join(uploadDir, 'shop', SHOP_KEY)), 'Bilder weg')
assert.ok(aw.docs('shopOrders').has('sh2_ord1') && aw.docs('shopConfig').has('f_sh2_shop'), 'anderer fälliger Kunde (nicht im Lauf) bleibt')
assert.ok(existsSync(path.join(uploadDir, 'shop', 'bcdefghijklmnopq')))
assert.ok(aw.docs('shopOrders').has('sh3_ord') && aw.docs('shopConfig').has('f_sh3_shop'), 'laufender Kunde bleibt')
// Reihenfolge: Shopdaten vor den Vertragsereignissen und Abo-Dokumenten
assert.ok(reihenfolge.lastIndexOf('shopConfig') < reihenfolge.indexOf('contractEvents'))
assert.ok(reihenfolge.indexOf('contractEvents') < reihenfolge.indexOf('subscriptions'))
assert.match(formatReport(plan, result), /Webshop: 1 Shop\(s\) gelöscht \(5 Dokument\(e\)/)
})
test('Webshop: scheitert das Löschen der Shopdaten, bleiben die Abo-Dokumente stehen (held) und der nächste Lauf setzt fort', async () => {
seedFaelligMitShop('sh4', 'defghijklmnopqrs')
const plan = await findDueDeletions(JAN_2026)
aw.failNext({ collection: 'shopOrders', method: 'DELETE', status: 500, message: 'kaputt' })
const result = await applyDeletions(planVon(plan, 'sh4'))
assert.deepEqual(result.held, ['sh4'])
assert.equal(result.failed[0].what, 'shopConfig')
assert.ok(aw.docs('subscriptions').has('sh4_sub') && aw.docs('contractEvents').has('sh4_ev'), 'Abo-Daten bleiben')
assert.ok(aw.docs('shopConfig').has('f_sh4_shop'), 'shopConfig bleibt, bis alles weg ist')
const zweiter = await findDueDeletions(JAN_2026)
assert.ok(zweiter.customers.some((c) => c.customerId === 'sh4'), 'der Kunde ist weiter fällig')
const nochmal = await applyDeletions(planVon(zweiter, 'sh4'))
assert.deepEqual(nochmal.failed, [])
assert.deepEqual(nochmal.held, [])
assert.ok(!aw.docs('shopConfig').has('f_sh4_shop') && !aw.docs('subscriptions').has('sh4_sub'))
})
+663
View File
@@ -0,0 +1,663 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Webshop E2: Abschluss einer bezahlten Sitzung (schliesseAb), Verwerfen, Abgleich offener Bestellungen (pruefeOffene), Minutenjob.
// Mails landen in der Senke (Kaeufer ueber termineOutbox, Haendler direkt), nichts verlaesst den Prozess.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const K = await import('../server/services/shop/kontext.js')
const { archiviereRechtstexte } = await import('../server/services/shop/archiv.js')
const B = await import('../server/services/shop/bestellungen.js')
const J = await import('../server/services/shop/jobs.js')
const { normalisiereProdukt } = await import('../server/services/shop/produkte.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
after(async () => {
setMailTransportForTests(null)
await aw.close()
await st.close()
})
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_shop'
const SITE_KEY = 'abcdefghijklmnop'
const ACCOUNT = 'acct_haendler1'
const T0 = new Date('2026-10-05T12:00:00.000Z')
const nach = (s) => new Date(T0.getTime() + s * 1000)
const PORTAL = 'https://project.webklar.com'
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
kleinunternehmer: true,
lieferzeit: '2–4 Werktage',
shopname: 'Brot & Mehr',
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' },
abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' },
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null },
zahlungsarten: ['card'],
}
const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' }
const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN)
const featureDoc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true }
const shopDoc = (over = {}) => ({
$id: FEATURE,
customerId: KUNDE,
projectId: PROJEKT,
siteKey: SITE_KEY,
anfrageStatus: 'angenommen',
stripeAccountId: ACCOUNT,
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }),
einstellungen: JSON.stringify(EINSTELLUNGEN),
rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }),
geoeffnet: true,
version: 1,
...over,
})
const artikel = (id, eingabe = {}) => {
const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...eingabe }, { einstellungen: { kleinunternehmer: true } })
assert.equal(r.fehler, undefined, JSON.stringify(r.fehler))
return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt) }
}
let sink
let log
let ctx
async function aufbau({ produkte = [artikel('a1', { bestand: 5 })], shop = {} } = {}) {
for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopRechtstexteArchiv', 'websiteProjects', 'termineOutbox', 'customers']) {
for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id)
}
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'https://www.brot.example' })
aw.seed('portalFeatures', featureDoc)
aw.seed('shopConfig', shopDoc(shop))
aw.seed('customers', { $id: KUNDE, name: 'Erika Muster', email: 'erika@brot.example' })
for (const p of produkte) aw.seed('shopProducts', p)
st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
K.leereCache()
ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
return ctx
}
beforeEach(() => {
st.calls.length = 0
config.publicBaseUrl = PORTAL
config.smtp.from = 'noreply@webklar.com'
config.shop.mode = 'on'
resetOutboxForTests()
sink = mailSink()
setMailTransportForTests(sink)
log = { warn: mock.method(console, 'warn', () => {}), error: mock.method(console, 'error', () => {}) }
K.leereCache()
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
})
const orders = () => [...aw.docs('shopOrders').values()]
const order = (id) => aw.docs('shopOrders').get(id)
const produkt = (id) => JSON.parse(aw.docs('shopProducts').get(id).produkt)
const eintraege = () => [...aw.docs('termineOutbox').values()]
const warnLog = () => log.warn.mock.calls.map((a) => a.arguments.map(String).join(' ')).join('\n')
const kunde = { name: 'Max Mustermann', email: 'max@example.com', phone: '+49 170 1234567', address: { line1: 'Rechnungsweg 1', line2: null, postal_code: '12345', city: 'Musterstadt', country: 'DE' } }
const versandAn = { name: 'Max Mustermann', address: { line1: 'Lieferweg 7', line2: 'Hinterhaus', postal_code: '67655', city: 'Kaiserslautern', country: 'DE', state: null } }
/** Bestellung anlegen (offen) und die zugehoerige Stripe-Sitzung liefern. */
async function bestelle(positionen = [{ produktId: 'a1', menge: 2 }], over = {}) {
const r = await B.legeBestellungAn(ctx, { positionen, lieferart: 'versand', land: 'DE', ...over }, { now: T0 })
const doc = order(r.orderId)
return { ...r, doc, sessionId: doc.stripeSessionId }
}
/** Sitzung als bezahlt markieren und so zurueckgeben, wie der Webhook sie schickt. */
const zahle = (b, felder = {}) =>
st.completeSession(ACCOUNT, b.sessionId, {
payment_status: 'paid',
amount_total: b.doc.summeCent,
customer_details: kunde,
shipping: versandAn,
payment_intent: 'pi_test1',
payment_method_types: ['card'],
...felder,
})
test('schliesseAb: Normalfall - bezahlt, Käuferdaten, Bestand abgezogen, G09 an den Käufer und K25 an den Händler ohne BCC', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b)
const erg = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(120) })
assert.equal(erg.neu, true)
assert.equal(erg.order.status, 'bezahlt')
const doc = order(b.orderId)
assert.equal(doc.status, 'bezahlt')
assert.equal(new Date(doc.bezahltAt).toISOString(), nach(120).toISOString())
assert.equal(doc.paymentIntentId, 'pi_test1')
assert.equal(doc.kaeuferEmail, 'max@example.com')
const daten = JSON.parse(doc.bestellung)
assert.deepEqual(daten.kaeufer, { name: 'Max Mustermann', email: 'max@example.com' }, 'Stripe liefert eine Telefonnummer, gespeichert wird sie nicht')
assert.ok(!doc.bestellung.includes('1234567'), 'keine Telefonnummer irgendwo im Bestell-JSON')
assert.deepEqual(daten.lieferadresse, { name: 'Max Mustermann', line1: 'Lieferweg 7', line2: 'Hinterhaus', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' })
assert.equal(daten.rechnungsadresse.line1, 'Rechnungsweg 1')
assert.equal(daten.zahlungsart, 'card')
assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'bezahlt'])
assert.equal(daten.ueberverkauft, undefined)
assert.equal(produkt('a1').bestand, 3)
await warteAufVersand()
const [g09] = eintraege()
assert.equal(eintraege().length, 1)
assert.equal(g09.art, 'G09')
assert.equal(g09.bookingId, b.orderId)
assert.equal(g09.featureId, FEATURE)
const mails = sink.sent
assert.equal(mails.length, 2)
const kaeufer = mails.find((m) => m.to[0].address === 'max@example.com')
const haendler = mails.find((m) => m.to[0].address === 'erika@brot.example')
assert.ok(kaeufer && haendler)
assert.match(kaeufer.subject, new RegExp(`Ihre Bestellung ${doc.bestellnummer} bei Brot & Mehr`))
assert.ok(kaeufer.text.includes('Lieferweg 7') && kaeufer.text.includes('Hinterhaus'))
assert.match(haendler.text, /Max Mustermann/)
assert.ok(!haendler.bcc && !kaeufer.bcc, 'kein BCC')
assert.doesNotMatch(haendler.text, /Bestand reichte nicht/)
assert.doesNotMatch(haendler.text, /Lastschrift/)
assert.equal(order(b.orderId).status, 'bezahlt')
})
test('schliesseAb I-6: G09 enthält die Rechtstexte der Bestellung (Archiv zum Hash), auch wenn der Händler sie danach geändert und neu bestätigt hat', async () => {
await aufbau()
const b = await bestelle()
await archiviereRechtstexte({ shopId: FEATURE, customerId: KUNDE, hash: HASH, at: T0.toISOString(), von: 'k@example.invalid', texte: { ...TEXTE, agb: 'AGB ZUM ZEITPUNKT DER BESTELLUNG', versandUndZahlung: 'x', gewaehrleistung: 'GEWÄHRLEISTUNG DAMALS' } })
// neue Fassung bestätigt, bevor bezahlt wird (SEPA: Tage später)
const neu = { ...TEXTE, agb: 'NEUE AGB NACH DER BESTELLUNG' }
aw.seed('shopConfig', shopDoc({ rechtstexte: JSON.stringify({ entwurf: neu, bestaetigt: { texte: neu, hash: 'e'.repeat(64), at: '2026-10-06T10:00:00.000Z', von: 'k@example.invalid' } }) }))
K.leereCache()
assert.equal(JSON.parse(order(b.orderId).bestellung).rechtstexteHash, HASH)
await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(120) })
await warteAufVersand()
const g09 = sink.sent.find((m) => m.to[0].address === 'max@example.com')
assert.match(g09.text, /AGB ZUM ZEITPUNKT DER BESTELLUNG/)
assert.match(g09.text, /GEWÄHRLEISTUNG DAMALS/)
assert.doesNotMatch(g09.text, /NEUE AGB/)
})
test('schliesseAb: idempotent - zweiter Aufruf ändert nichts, keine zweite Mail, Bestand nur einmal', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b)
const [a, z] = await Promise.all([B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) }), B.schliesseAb({ accountId: ACCOUNT, session, now: nach(61) })])
assert.deepEqual([a.neu, z.neu].sort(), [false, true])
const dritte = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(300) })
assert.equal(dritte.neu, false)
assert.equal(dritte.order.status, 'bezahlt')
await warteAufVersand()
assert.equal(eintraege().length, 1)
assert.equal(sink.sent.length, 2)
assert.equal(produkt('a1').bestand, 3)
assert.equal(JSON.parse(order(b.orderId).bestellung).verlauf.filter((v) => v.was === 'bezahlt').length, 1)
})
test('schliesseAb: falsches Konto, fremde oder unbekannte Sitzung ändern nichts', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b)
assert.deepEqual(await B.schliesseAb({ accountId: 'acct_fremd', session, now: nach(60) }), { order: null, neu: false })
assert.match(warnLog(), /gehört nicht zum Stripe-Konto/)
assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { ...session.metadata, wkShop: undefined } }, now: nach(60) }), { order: null, neu: false })
assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { wkShop: '1', orderId: 'gibt-es-nicht' } }, now: nach(60) }), { order: null, neu: false })
assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { wkShop: '1' } }, now: nach(60) }), { order: null, neu: false })
assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: null, now: nach(60) }), { order: null, neu: false })
assert.equal(order(b.orderId).status, 'offen')
assert.equal(produkt('a1').bestand, 5)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
})
test('schliesseAb: SEPA noch nicht bezahlt ist wirkungslos, bis async_payment_succeeded kommt; SEPA-Hinweis in K25', async () => {
await aufbau()
const b = await bestelle()
const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] })
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: unbezahlt, now: nach(60) })
assert.equal(erg.neu, false)
assert.equal(erg.order.status, 'offen')
assert.equal(produkt('a1').bestand, 5)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
const bezahlt = zahle(b, { payment_status: 'paid', payment_method_types: ['sepa_debit'] })
const spaeter = await B.schliesseAb({ accountId: ACCOUNT, session: bezahlt, now: nach(3 * 86400) })
assert.equal(spaeter.neu, true)
assert.equal(JSON.parse(order(b.orderId).bestellung).zahlungsart, 'sepa_debit')
await warteAufVersand()
assert.match(sink.sent.find((m) => m.to[0].address === 'erika@brot.example').text, /Lastschrift/)
})
test('meldeEingang I-1: nur bei unbezahlter, abgeschlossener Sitzung einer offenen Bestellung - einmal G15 an die Adresse der Sitzung, Marker, kein Vertragstext', async () => {
await aufbau()
const b = await bestelle()
const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] })
assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: { ...unbezahlt, payment_status: 'paid' }, now: nach(60) }), { gesendet: false }, 'bezahlt ist Sache von schliesseAb')
assert.deepEqual(await B.meldeEingang({ accountId: 'acct_fremd', session: unbezahlt, now: nach(60) }), { gesendet: false }, 'fremdes Konto')
assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: { ...unbezahlt, customer_details: null }, now: nach(60) }), { gesendet: false }, 'ohne Adresse')
await warteAufVersand()
assert.equal(sink.sent.length, 0)
assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(60) }), { gesendet: true })
assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(120) }), { gesendet: false }, 'nur einmal')
await warteAufVersand()
assert.equal(sink.sent.length, 1)
assert.equal(sink.sent[0].to[0].address, 'max@example.com')
assert.match(sink.sent[0].subject, /^Wir haben Ihre Bestellung \S+ erhalten$/)
assert.doesNotMatch(sink.sent[0].text, /Kaufvertrag ist damit geschlossen/)
assert.equal(order(b.orderId).status, 'offen')
assert.equal(JSON.parse(order(b.orderId).bestellung).mailsEingangAt, nach(60).toISOString())
// Später bezahlt: G09 folgt, der Marker bleibt erhalten
await B.schliesseAb({ accountId: ACCOUNT, session: { ...unbezahlt, payment_status: 'paid' }, now: nach(3 * 86400) })
await warteAufVersand()
assert.equal(JSON.parse(order(b.orderId).bestellung).mailsEingangAt, nach(60).toISOString())
assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(4 * 86400) }), { gesendet: false }, 'nicht mehr offen')
})
test('meldeEingang I-1: scheitert das Einreihen, bleibt der Marker leer und die Versuche werden gezählt (höchstens 5)', async () => {
await aufbau()
const b = await bestelle()
const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] })
await deleteDocument(config.collections.portalFeatures, FEATURE) // Shop-Kontext fehlt: das Einreihen scheitert
K.leereCache()
for (let i = 0; i < 7; i += 1) assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(60 + i) }), { gesendet: false })
const d = JSON.parse(order(b.orderId).bestellung)
assert.equal(d.mailsEingangAt, undefined)
assert.equal(d.mailsVersuche.eingang, 5)
assert.match(warnLog(), /G15 zur Bestellung/)
})
test('pruefeOffene I-1: SEPA unterwegs - der Minutenjob holt die Eingangsmail G15 nach, ein zweiter Lauf schickt keine zweite', async () => {
await aufbau()
const sepa = await bestelle([{ produktId: 'a1', menge: 1 }])
zahle(sepa, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] })
const spaet = nach(30 * 60 + 30 + 6 * 60)
const z = await B.pruefeOffene(spaet)
assert.deepEqual(z, { geprueft: 1, bezahlt: 0, verworfen: 0, wartend: 1, fehler: 0 })
await warteAufVersand()
assert.equal(sink.sent.length, 1)
assert.equal(sink.sent[0].to[0].address, 'max@example.com')
await B.pruefeOffene(nach(30 * 60 + 30 + 8 * 60))
await warteAufVersand()
assert.equal(sink.sent.length, 1)
assert.equal(order(sepa.orderId).status, 'offen')
})
test('schliesseAb: späte Zahlung nach Ablauf wird angenommen, andere Stornos nicht', async () => {
await aufbau()
const a = await bestelle()
assert.equal((await B.verwirf(a.orderId, 'abgelaufen', { now: nach(1900) })).verworfen, true)
assert.equal(order(a.orderId).status, 'storniert')
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(a), now: nach(2000) })
assert.equal(erg.neu, true)
assert.equal(order(a.orderId).status, 'bezahlt')
const daten = JSON.parse(order(a.orderId).bestellung)
assert.equal(daten.spaet, true)
assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'abgelaufen', 'bezahlt'])
assert.equal(produkt('a1').bestand, 3)
const c = await bestelle([{ produktId: 'a1', menge: 1 }])
assert.equal((await B.verwirf(c.orderId, 'zahlung_fehlgeschlagen', { now: nach(10) })).verworfen, true)
const nein = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(c), now: nach(20) })
assert.equal(nein.neu, false)
assert.equal(order(c.orderId).status, 'storniert')
assert.equal(produkt('a1').bestand, 3)
})
test('schliesseAb: abweichender Betrag - trotzdem bezahlt, Vermerk im Verlauf, Warnung ohne Käuferdaten', async () => {
await aufbau()
const b = await bestelle()
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b, { amount_total: b.doc.summeCent + 100 }), now: nach(60) })
assert.equal(erg.neu, true)
const daten = JSON.parse(order(b.orderId).bestellung)
const vermerk = daten.verlauf.find((v) => v.was === 'betrag_abweichend')
assert.deepEqual({ e: vermerk.erwartetCent, b: vermerk.bezahltCent }, { e: b.doc.summeCent, b: b.doc.summeCent + 100 })
assert.match(warnLog(), /weicht vom Bestellwert ab/)
assert.doesNotMatch(warnLog(), /max@example|Mustermann|Lieferweg/)
})
test('schliesseAb: Variantenbestand wird in der Variante abgezogen, Artikel ohne Bestand bleiben unberührt', async () => {
await aufbau({
produkte: [
artikel('a1', { bestand: 10, varianten: { name: 'Größe', werte: [{ name: 'M', preisCent: 2000, bestand: 4 }, { name: 'L', preisCent: 2200, bestand: 6 }] } }),
artikel('a2'),
],
})
const werte = produkt('a1').varianten.werte
const L = werte.find((w) => w.name === 'L')
const M = werte.find((w) => w.name === 'M')
const b = await bestelle([{ produktId: 'a1', varianteId: L.id, menge: 2 }, { produktId: 'a1', varianteId: M.id, menge: 1 }, { produktId: 'a2', menge: 3 }])
await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) })
const danach = produkt('a1')
assert.equal(danach.varianten.werte.find((w) => w.name === 'L').bestand, 4)
assert.equal(danach.varianten.werte.find((w) => w.name === 'M').bestand, 3)
assert.equal(danach.bestand, null, 'bei Varianten führen die Varianten den Bestand')
assert.equal(produkt('a2').bestand, null)
})
test('schliesseAb: Bestand reicht nicht - auf 0 gesetzt, Händler-Hinweis ueberverkauft in K25', async () => {
await aufbau()
const b = await bestelle([{ produktId: 'a1', menge: 4 }])
// Der Haendler hat den Bestand inzwischen verringert
const p = produkt('a1')
p.bestand = 1
await updateDocument(config.collections.shopProducts, 'a1', { produkt: JSON.stringify(p) })
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) })
assert.equal(erg.neu, true)
assert.equal(produkt('a1').bestand, 0)
assert.equal(JSON.parse(order(b.orderId).bestellung).ueberverkauft, true)
await warteAufVersand()
assert.match(sink.sent.find((m) => m.to[0].address === 'erika@brot.example').text, /Bestand reichte nicht/)
assert.equal(order(b.orderId).status, 'bezahlt')
})
test('schliesseAb: Mailfehler machen die Zahlung nicht rückgängig', async () => {
await aufbau()
const b = await bestelle()
aw.docs('customers').clear()
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) })
assert.equal(erg.neu, true)
assert.equal(order(b.orderId).status, 'bezahlt')
assert.equal(produkt('a1').bestand, 3)
})
test('schliesseAb: Abholung ohne Lieferadresse, Telefon und Namen fehlen', async () => {
await aufbau()
const b = await bestelle([{ produktId: 'a1', menge: 1 }], { lieferart: 'abholung', land: undefined })
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b, { shipping: null, customer_details: { email: 'x@example.com' } }), now: nach(60) })
assert.equal(erg.neu, true)
const daten = JSON.parse(order(b.orderId).bestellung)
assert.equal(daten.lieferadresse, undefined)
assert.equal(daten.rechnungsadresse, undefined)
assert.deepEqual(daten.kaeufer, { name: '', email: 'x@example.com' })
})
test('IP-Hash: nach der Zahlung weg (auch bei später Zahlung einer verworfenen Bestellung), vorher da', async () => {
await aufbau()
const r = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' })
const b = { ...r, doc: order(r.orderId), sessionId: order(r.orderId).stripeSessionId }
assert.equal(JSON.parse(b.doc.bestellung).ipHash, 'abcdef0123456789', 'solange offen, steht der IP-Hash im Datensatz')
await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) })
assert.equal(order(b.orderId).status, 'bezahlt')
assert.equal(JSON.parse(order(b.orderId).bestellung).ipHash, undefined, 'bezahlt: kein IP-Hash mehr')
assert.ok(!order(b.orderId).bestellung.includes('ipHash'))
// spaete Zahlung: verworfen (IP-Hash weg), danach bezahlt - bleibt ohne IP-Hash
const r2 = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' })
const b2 = { ...r2, doc: order(r2.orderId), sessionId: order(r2.orderId).stripeSessionId }
await B.verwirf(r2.orderId, 'abgelaufen', { now: nach(1900) })
await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b2), now: nach(2000) })
assert.equal(order(r2.orderId).status, 'bezahlt')
assert.ok(!order(r2.orderId).bestellung.includes('ipHash'))
})
test('IP-Hash: verwirf entfernt ihn, Verlauf und übrige Daten bleiben', async () => {
await aufbau()
const r = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' })
assert.equal(JSON.parse(order(r.orderId).bestellung).ipHash, 'abcdef0123456789')
const erg = await B.verwirf(r.orderId, 'abgelaufen', { now: nach(1900) })
assert.equal(erg.verworfen, true)
const daten = JSON.parse(order(r.orderId).bestellung)
assert.equal(daten.ipHash, undefined)
assert.equal(erg.order.daten.ipHash, undefined)
assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'abgelaufen'])
assert.equal(daten.positionen.length, 1)
})
test('verwirf: nur aus offen; Verlauf, Reservierung frei; G14 nur mit mail und bekannter Adresse', async () => {
await aufbau()
const a = await bestelle()
assert.equal((await B.reserviert(FEATURE, nach(10))).get('a1'), 2)
const erg = await B.verwirf(a.orderId, 'abgelaufen', { now: nach(10) })
assert.equal(erg.verworfen, true)
assert.equal(erg.order.status, 'storniert')
assert.equal(JSON.parse(order(a.orderId).bestellung).verlauf.at(-1).was, 'abgelaufen')
assert.equal((await B.reserviert(FEATURE, nach(10))).get('a1'), undefined)
assert.equal((await B.verwirf(a.orderId, 'abgelaufen', { now: nach(11) })).verworfen, false, 'zweites Mal wirkungslos')
assert.equal((await B.verwirf('gibt-es-nicht', 'abgelaufen')).order, null)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
// bezahlt wird nie verworfen
const bz = await bestelle()
await B.schliesseAb({ accountId: ACCOUNT, session: zahle(bz), now: nach(20) })
assert.equal((await B.verwirf(bz.orderId, 'abgelaufen', { now: nach(30) })).verworfen, false)
assert.equal(order(bz.orderId).status, 'bezahlt')
// falsches Konto
const f = await bestelle([{ produktId: 'a1', menge: 1 }])
assert.equal((await B.verwirf(f.orderId, 'abgelaufen', { accountId: 'acct_fremd' })).verworfen, false)
assert.equal(order(f.orderId).status, 'offen')
// G14 mit der Adresse aus der Sitzung
sink.sent.length = 0
const g = await bestelle([{ produktId: 'a1', menge: 1 }])
const v = await B.verwirf(g.orderId, 'zahlung_fehlgeschlagen', { now: nach(40), mail: true, email: 'max@example.com', accountId: ACCOUNT })
assert.equal(v.verworfen, true)
await warteAufVersand()
const g14 = eintraege().filter((e) => e.art === 'G14')
assert.equal(g14.length, 1)
assert.equal(sink.sent.length, 1)
assert.equal(sink.sent[0].to[0].address, 'max@example.com')
// ohne Adresse keine Mail, kein Fehler
const h = await bestelle([{ produktId: 'a1', menge: 1 }])
assert.equal((await B.verwirf(h.orderId, 'zahlung_fehlgeschlagen', { mail: true })).verworfen, true)
await warteAufVersand()
assert.equal(eintraege().filter((e) => e.art === 'G14').length, 1)
})
test('pruefeOffene: bezahlt abschließen, abgelaufene und noch offene Sitzungen verwerfen, Lesefehler und junge Bestellungen überspringen', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 20 })] })
const bezahlt = await bestelle([{ produktId: 'a1', menge: 1 }])
const abgelaufen = await bestelle([{ produktId: 'a1', menge: 1 }])
const offen = await bestelle([{ produktId: 'a1', menge: 1 }])
const sepa = await bestelle([{ produktId: 'a1', menge: 1 }])
const fehler = await bestelle([{ produktId: 'a1', menge: 1 }])
zahle(bezahlt)
st.accountSessions(ACCOUNT).set(abgelaufen.sessionId, { ...st.accountSessions(ACCOUNT).get(abgelaufen.sessionId), status: 'expired' })
zahle(sepa, { payment_status: 'unpaid' })
const spaet = nach(30 * 60 + 30 + 6 * 60) // Frist + 6 Minuten
const jung = await (async () => {
const x = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: nach(10 * 60) })
return x
})()
st.failNext({ method: 'GET', path: `/v1/checkout/sessions/${fehler.sessionId}`, status: 400 })
st.calls.length = 0
const z = await B.pruefeOffene(spaet)
assert.deepEqual(z, { geprueft: 5, bezahlt: 1, verworfen: 2, wartend: 1, fehler: 1 })
assert.equal(order(bezahlt.orderId).status, 'bezahlt')
assert.equal(order(abgelaufen.orderId).status, 'storniert')
assert.equal(order(offen.orderId).status, 'storniert')
assert.ok(st.calls.some((c) => c.method === 'POST' && c.path === `/v1/checkout/sessions/${offen.sessionId}/expire` && c.account === ACCOUNT))
assert.equal(st.calls.some((c) => c.path.endsWith('/expire') && c.path.includes(abgelaufen.sessionId)), false, 'bereits abgelaufene Sitzung wird nicht erneut beendet')
assert.equal(order(sepa.orderId).status, 'offen', 'SEPA unterwegs bleibt offen')
assert.equal(order(fehler.orderId).status, 'offen', 'Lesefehler: nächster Lauf')
assert.equal(order(jung.orderId).status, 'offen', 'Frist noch nicht um mehr als 5 Minuten verstrichen')
assert.match(warnLog(), /Abgleich der Bestellung/)
assert.equal(produkt('a1').bestand, 19)
// Nächster Lauf: der Lesefehler ist weg
const z2 = await B.pruefeOffene(spaet)
assert.deepEqual({ ...z2 }, { geprueft: 2, bezahlt: 0, verworfen: 1, wartend: 1, fehler: 0 })
assert.equal(order(fehler.orderId).status, 'storniert')
})
test('pruefeOffene: Bestellung ohne gespeicherte Sitzung wird verworfen; ohne offene Bestellungen passiert nichts', async () => {
await aufbau()
assert.deepEqual(await B.pruefeOffene(nach(7200)), { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 })
const b = await bestelle()
await updateDocument(config.collections.shopOrders, b.orderId, { stripeSessionId: '' })
const z = await B.pruefeOffene(nach(7200))
assert.equal(z.verworfen, 1)
assert.equal(order(b.orderId).status, 'storniert')
})
test('minutenJob: bei SHOP_ENABLED=off aus, sonst Abgleich; läuft nicht doppelt gleichzeitig', async () => {
await aufbau()
const b = await bestelle()
zahle(b)
config.shop.mode = 'off'
assert.deepEqual(await J.minutenJob(nach(7200)), { aus: true })
assert.equal(order(b.orderId).status, 'offen')
config.shop.mode = 'pilot'
const [erster, zweiter] = await Promise.all([J.minutenJob(nach(7200)), J.minutenJob(nach(7200))])
assert.ok(erster.bezahlt === 1 || zweiter.bezahlt === 1)
assert.ok(erster.uebersprungen === true || zweiter.uebersprungen === true)
assert.equal(order(b.orderId).status, 'bezahlt')
assert.deepEqual(await J.minutenJob(nach(7300)), { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 })
})
// ---------------------------------------------------------------------------------------------------------------------
// Review A: I1 (Statuswechsel und Bestandsabzug unter einer Sperre) und I2 (fehlende Schritte nachholen)
test('schliesseAb I1: Statuswechsel und Bestandsabzug laufen unter der Bestandssperre - eine parallele Bestellung kann den letzten Artikel nicht noch einmal bekommen', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 2 }), artikel('a2', {})] })
const b = await bestelle([{ produktId: 'a1', menge: 2 }])
const session = zahle(b)
const echtesFetch = globalThis.fetch
let konkurrenz = null
globalThis.fetch = async (url, opt = {}) => {
const antwort = await echtesFetch(url, opt)
const pfad = String(url)
if (!konkurrenz && opt.method === 'PATCH' && pfad.includes(`/${config.collections.shopOrders}/documents/${b.orderId}`) && String(opt.body).includes('"bezahlt"')) {
// Die Bestellung ist jetzt bezahlt, der Bestand noch nicht abgezogen: genau jetzt kommt ein zweiter Kaeufer
konkurrenz = B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 2 }], lieferart: 'versand', land: 'DE' }, { now: nach(61) }).then(
(r) => ({ ok: true, r }),
(e) => ({ ok: false, e })
)
await new Promise((r) => setTimeout(r, 80))
}
return antwort
}
try {
await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) })
const zweiter = await konkurrenz
assert.equal(zweiter.ok, false, 'der letzte Artikel darf nicht noch einmal reserviert werden')
assert.equal(zweiter.e.code, 'ausverkauft')
} finally {
globalThis.fetch = echtesFetch
}
assert.equal(produkt('a1').bestand, 0)
assert.equal(orders().filter((o) => o.status === 'offen').length, 0)
})
test('schliesseAb I2: scheitert der Bestandsabzug, bleibt der Artikel reserviert; der zweite Lauf holt den Abzug einmal nach und verschickt die Mails einmal', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 5 })] })
const b = await bestelle([{ produktId: 'a1', menge: 2 }])
const session = zahle(b)
aw.failNext({ collection: config.collections.shopProducts, method: 'PATCH', status: 500 })
await assert.rejects(B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) }))
assert.equal(order(b.orderId).status, 'bezahlt')
const daten = JSON.parse(order(b.orderId).bestellung)
assert.equal(daten.bestandAbgezogenAt, null)
assert.equal(produkt('a1').bestand, 5)
assert.equal((await B.reserviert(FEATURE, nach(70))).get('a1'), 2, 'noch nicht abgezogen => weiter reserviert')
await warteAufVersand()
assert.equal(sink.sent.length, 0)
const wieder = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(120) })
assert.equal(wieder.neu, false)
assert.equal(produkt('a1').bestand, 3)
const nachher = JSON.parse(order(b.orderId).bestellung)
assert.ok(nachher.bestandAbgezogenAt && nachher.mailsKaeuferAt && nachher.mailsHaendlerAt)
assert.equal((await B.reserviert(FEATURE, nach(130))).get('a1'), undefined)
await warteAufVersand()
assert.equal(sink.sent.length, 2)
await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(180) })
await warteAufVersand()
assert.equal(produkt('a1').bestand, 3, 'Bestand nur einmal')
assert.equal(sink.sent.length, 2, 'Mails nur einmal')
assert.equal(eintraege().length, 1)
})
let kaputt = false
const kaputtSchalter = () => (kaputt = false) || setMailTransportForTests({ sent: sink.sent, sendMail: (mail) => (kaputt ? Promise.reject(new Error('smtp kaputt')) : sink.sendMail(mail)) })
test('schliesseAb I2: G09 und K25 werden einzeln nachgeholt, sobald sie scheitern, und nie doppelt verschickt', async () => {
await aufbau()
kaputtSchalter()
const b = await bestelle()
const session = zahle(b)
aw.failNext({ collection: config.collections.termineOutbox, method: 'POST', status: 500 })
kaputt = true
const erst = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) })
assert.equal(erst.neu, true)
await warteAufVersand()
let daten = JSON.parse(order(b.orderId).bestellung)
assert.equal(daten.mailsKaeuferAt, null, 'G09 nicht eingereiht => offen')
assert.equal(daten.mailsHaendlerAt, null, 'K25 nicht gesendet => offen')
assert.equal(sink.sent.length, 0)
kaputt = false
await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(120) })
await warteAufVersand()
daten = JSON.parse(order(b.orderId).bestellung)
assert.ok(daten.mailsKaeuferAt && daten.mailsHaendlerAt)
assert.equal(sink.sent.length, 2)
assert.equal(produkt('a1').bestand, 3, 'Bestand blieb bei einmal abgezogen')
await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(240) })
await warteAufVersand()
assert.equal(sink.sent.length, 2)
assert.equal(eintraege().length, 1)
})
test('schliesseAb I2: ein dauerhaft scheiternder Mailschritt wird nicht endlos wiederholt', async () => {
await aufbau()
kaputtSchalter()
const b = await bestelle()
const session = zahle(b)
kaputt = true
for (let i = 0; i < 8; i += 1) await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60 + i) })
const daten = JSON.parse(order(b.orderId).bestellung)
assert.equal(daten.mailsHaendlerAt, null)
assert.ok(daten.mailsVersuche.haendler <= 5)
assert.equal(B.abschlussAusstehend(order(b.orderId)), false, 'nach dem Versuchslimit nichts mehr ausstehend')
})
test('pruefeOffene I2: bezahlte Bestellungen mit fehlenden Schritten werden vom Minutenjob nachgeholt', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 5 })] })
const b = await bestelle([{ produktId: 'a1', menge: 2 }])
const session = zahle(b)
aw.failNext({ collection: config.collections.shopProducts, method: 'PATCH', status: 500 })
await assert.rejects(B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) }))
const z = await B.pruefeOffene(nach(60 + 6 * 60))
assert.equal(z.fehler, 0)
assert.equal(produkt('a1').bestand, 3)
await warteAufVersand()
assert.equal(sink.sent.length, 2)
})
+403
View File
@@ -0,0 +1,403 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Webshop B1: Anfrage stellen, zurueckziehen, Stand lesen (services/shop/anfrage.js, routes/shop.js), T21 ans Team,
// Sperre in der Kundenansicht (kundenansichtGuard, Router)
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), shopConfig: schemaKeys('shopConfig'), featureConsents: schemaKeys('featureConsents') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, send, get, post } = await import('./support/testApp.js')
const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js')
const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const anfrage = await import('../server/services/shop/anfrage.js')
const sf = await import('../server/services/siteFeatures.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { featureHaken } = await import('../server/services/featureRegistry.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes })
const mails = mailSink()
mails.versandFehler = null
const sinkMail = mails.sendMail.bind(mails)
mails.sendMail = async (mail) => {
if (mails.versandFehler) throw new Error('SMTP nicht erreichbar')
return sinkMail(mail)
}
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Töpferei', archived: false })
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' })
aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Altprojekt', archived: true })
const cookie = customerCookie('c1')
const GUELTIG = {
warenart: 'kunst',
beschreibung: 'Handgemachte Keramik und Tassen aus eigener Werkstatt.',
artikelzahl: 'bis20',
versand: true,
abholung: false,
unternehmer: true,
keineAusgeschlossenen: true,
nachricht: 'Wir würden gern vor Weihnachten starten.',
}
const stelle = (body = {}, opts = { cookie }) => post('/api/shop/anfrage', { projectId: 'pt1', anfrage: GUELTIG, ...body }, opts)
const zurueck = (projectId = 'pt1', opts = { cookie }) => post('/api/shop/anfrage/zurueckziehen', { projectId }, opts)
const status = (projectId = 'pt1', opts = { cookie }) => send('GET', `/api/shop/status?projectId=${projectId}`, opts)
const t21 = () => mails.sent.filter((m) => /^\[Webshop\]/.test(m.subject))
const shopDoc = (projectId = 'pt1') => aw.docs('shopConfig').get(`${projectId}`) ?? null
let origin
beforeEach(() => {
origin ??= { mode: config.shop.mode, pilotEmails: config.shop.pilotEmails }
config.shop.mode = 'pilot'
config.shop.pilotEmails = [customers.c1.email]
mails.versandFehler = null
resetShopRoutesForTests()
})
async function leereAlles() {
for (const col of ['portalFeatures', 'shopConfig']) for (const id of aw.docs(col).keys()) await deleteDocument(config.collections[col], id)
mails.sent.length = 0
}
test('normalisiereAnfrage: gueltige Eingabe, bereinigt; Fehler je Feld', () => {
const { anfrage: ok, fehler } = anfrage.normalisiereAnfrage({ ...GUELTIG, beschreibung: ' Handgemachte Tassen und mehr ', extra: 'x' })
assert.equal(fehler, undefined)
assert.deepEqual(Object.keys(ok).sort(), ['abholung', 'artikelzahl', 'beschreibung', 'keineAusgeschlossenen', 'nachricht', 'unternehmer', 'versand', 'warenart'])
assert.equal(ok.beschreibung, 'Handgemachte Tassen und mehr')
const f = (aenderung) => anfrage.normalisiereAnfrage({ ...GUELTIG, ...aenderung }).fehler
assert.ok(f({ warenart: 'drogen' }).warenart)
assert.ok(f({ warenart: 'constructor' }).warenart, 'kein Treffer ueber die Prototypkette')
assert.ok(f({ beschreibung: 'kurz' }).beschreibung)
assert.ok(f({ beschreibung: 'x'.repeat(1501) }).beschreibung)
assert.equal(f({ beschreibung: 'x'.repeat(1500) }), undefined)
assert.ok(f({ artikelzahl: 'viele' }).artikelzahl)
assert.ok(f({ versand: false, abholung: false }).versand, 'mindestens eins von Versand und Abholung')
assert.equal(f({ versand: false, abholung: true }), undefined)
assert.ok(f({ versand: 'ja' }).versand)
assert.ok(f({ unternehmer: 'true' }).unternehmer)
assert.ok(f({ unternehmer: false }).unternehmer)
assert.ok(f({ keineAusgeschlossenen: undefined }).keineAusgeschlossenen)
assert.ok(f({ nachricht: 'n'.repeat(1001) }).nachricht)
assert.equal(f({ nachricht: undefined }), undefined)
assert.equal(anfrage.normalisiereAnfrage({ ...GUELTIG, nachricht: undefined }).anfrage.nachricht, '')
assert.ok(anfrage.normalisiereAnfrage(null).fehler.warenart)
assert.ok(anfrage.normalisiereAnfrage([]).fehler.beschreibung)
})
test('GET /status: ohne Dokumente off und leer, legt nichts an; Schalter, fremdes Projekt, ohne Projekt', async () => {
await leereAlles()
const res = await status()
assert.equal(res.status, 200)
assert.deepEqual(res.body, {
verfuegbar: true, state: 'off', ticketWoid: null, anfrage: null, bereitschaft: null, geoeffnet: false, schalter: false, siteKey: null,
snippet: null, vorschauUrl: null, stripe: { verbunden: false, status: null }, zahlen: null,
})
assert.equal(res.headers.get('cache-control'), 'no-store')
assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0, 'GET legt nichts an')
config.shop.mode = 'off'
assert.equal((await status()).body.verfuegbar, false)
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['jemand-anderes@example.invalid']
assert.equal((await status()).body.verfuegbar, false, 'Pilot: nur gelistete Adressen')
assert.equal((await status('pt3')).status, 403)
assert.equal((await status('gibtsnicht')).status, 403)
assert.equal((await send('GET', '/api/shop/status', { cookie })).status, 400)
assert.equal((await send('GET', '/api/shop/status?projectId=pt1')).status, 401)
})
test('POST /anfrage: Schalter aus => 409, nichts angelegt', async () => {
await leereAlles()
config.shop.mode = 'off'
const res = await stelle()
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Der Webshop ist noch nicht verfügbar.')
assert.equal(res.body.code, 'nicht_verfuegbar')
config.shop.mode = 'pilot'
config.shop.pilotEmails = []
assert.equal((await stelle()).status, 409)
assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0)
assert.equal(t21().length, 0)
})
test('POST /anfrage: fremdes oder archiviertes Projekt, ohne Projekt, ungueltige Eingabe => nichts angelegt, keine Mail', async () => {
await leereAlles()
assert.equal((await stelle({ projectId: 'pt3' })).status, 403)
assert.equal((await stelle({ projectId: 'unbekannt' })).status, 403)
assert.equal((await stelle({ projectId: 'pt4' })).status, 409, 'archiviert')
const ohne = await post('/api/shop/anfrage', { anfrage: GUELTIG }, { cookie })
assert.equal(ohne.status, 400)
assert.equal(ohne.body.error, 'Bitte wähle ein Projekt.')
const schlecht = await stelle({ anfrage: { ...GUELTIG, beschreibung: 'kurz', unternehmer: false } })
assert.equal(schlecht.status, 400)
assert.ok(schlecht.body.fehler.beschreibung && schlecht.body.fehler.unternehmer)
assert.equal(schlecht.body.error, Object.values(schlecht.body.fehler)[0])
assert.equal((await stelle({ anfrage: 'text' })).status, 400)
assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0)
assert.equal(t21().length, 0)
assert.equal((await post('/api/shop/anfrage', { projectId: 'pt1', anfrage: GUELTIG })).status, 401)
})
test('POST /anfrage: speichert, Status offen, shopConfig-ID = portalFeatures-ID, T21 ans Team', async () => {
await leereAlles()
const res = await stelle()
assert.equal(res.status, 200)
assert.equal(res.body.anfrage.status, 'offen')
assert.deepEqual(res.body.anfrage.anfrage, { ...GUELTIG })
assert.match(res.body.anfrage.at, /^\d{4}-\d\d-\d\dT/)
assert.equal(res.body.anfrage.begruendung, '')
const feature = await sf.getFeatureDoc('c1', 'pt1', 'shop')
assert.equal(feature.state, 'off', 'Funktion bleibt off, die Anfrage ist keine Bestellung')
const shop = aw.docs('shopConfig').get(feature.$id)
assert.ok(shop, 'shopConfig mit der ID des portalFeatures-Dokuments')
assert.deepEqual([shop.customerId, shop.projectId, shop.siteKey, shop.anfrageStatus, shop.version], ['c1', 'pt1', feature.siteKey, 'offen', 1])
assert.deepEqual(JSON.parse(shop.anfrage), GUELTIG)
assert.equal(aw.docs('shopConfig').size, 1)
// GET zeigt dasselbe
const stand = await status()
assert.deepEqual([stand.body.verfuegbar, stand.body.state, stand.body.anfrage.status], [true, 'off', 'offen'])
assert.deepEqual(stand.body.anfrage.anfrage, GUELTIG)
// T21
assert.equal(t21().length, 1)
const mail = t21()[0]
assert.equal(mail.subject, '[Webshop] Neue Anfrage: Kunde Eins')
assert.ok(mail.to.length > 0 && mail.to.every((x) => config.chatNotify.emails.includes(x.address)), 'nur ans Team')
for (const teil of ['k@example.invalid', 'Töpferei', 'Kunst', 'bis 20', 'Versand', 'Handgemachte Keramik', 'vor Weihnachten']) assert.ok(mail.text.includes(teil), `T21 nennt ${teil}\n${mail.text}`)
assert.ok(!/Abholung/.test(mail.text.split('Lieferung')[1].split('\n')[0]), 'Lieferung: nur Versand')
assert.ok(mail.html.includes(`${config.womsBaseUrl}/customers/c1?tab=funktionen`), 'Link zum Kunden im Ticketsystem')
})
test('POST /anfrage: Lieferung beides; ohne Nachricht steht ein Strich; Rohtext wird maskiert', async () => {
await leereAlles()
const res = await stelle({ anfrage: { ...GUELTIG, versand: true, abholung: true, nachricht: undefined, beschreibung: 'Tassen <b>fett</b> & mehr' } })
assert.equal(res.status, 200)
const mail = t21()[0]
assert.ok(mail.text.includes('Versand und Abholung'))
assert.ok(mail.html.includes('Tassen &lt;b&gt;fett&lt;/b&gt; &amp; mehr'))
assert.ok(!mail.html.includes('<b>fett</b>'))
assert.match(mail.text, /Nachricht:\s*–/)
})
test('zweite Anfrage bei offener => 409, keine zweite Mail; zurueckziehen, dann wieder stellen (Version zaehlt hoch)', async () => {
await leereAlles()
assert.equal((await stelle()).status, 200)
const doppelt = await stelle()
assert.equal(doppelt.status, 409)
assert.equal(doppelt.body.code, 'anfrage_laeuft')
assert.equal(t21().length, 1)
const zu = await zurueck()
assert.equal(zu.status, 200)
assert.equal(zu.body.anfrage.status, 'zurueckgezogen')
assert.equal(shopDoc(), null, 'ID ist die des portalFeatures-Dokuments, nicht die Projekt-ID')
const feature = await sf.getFeatureDoc('c1', 'pt1', 'shop')
assert.equal(aw.docs('shopConfig').get(feature.$id).version, 2)
const nochmal = await zurueck()
assert.equal(nochmal.status, 409)
assert.equal(nochmal.body.code, 'nicht_offen')
assert.equal(nochmal.body.error, 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.')
const neu = await stelle({ anfrage: { ...GUELTIG, warenart: 'kleidung' } })
assert.equal(neu.status, 200)
assert.equal(neu.body.anfrage.status, 'offen')
assert.equal(neu.body.anfrage.anfrage.warenart, 'kleidung')
assert.equal(aw.docs('shopConfig').get(feature.$id).version, 3)
assert.equal(aw.docs('shopConfig').size, 1, 'immer dasselbe Dokument')
assert.equal(t21().length, 2)
})
test('zurueckziehen ohne Anfrage, bei fremdem Projekt, bei ausgeschaltetem Schalter', async () => {
await leereAlles()
assert.equal((await zurueck()).status, 409, 'es gibt keine Anfrage')
assert.equal((await zurueck('pt3')).status, 403)
assert.equal((await post('/api/shop/anfrage/zurueckziehen', {}, { cookie })).status, 400)
assert.equal(aw.docs('portalFeatures').size, 0, 'zurueckziehen legt nichts an')
config.shop.mode = 'off'
const aus = await zurueck()
assert.equal(aus.status, 409)
assert.equal(aus.body.code, 'nicht_verfuegbar')
})
test('abgelehnt erlaubt eine neue Anfrage und leert die Entscheidung; angenommen und andere Zustaende nicht', async () => {
await leereAlles()
assert.equal((await stelle()).status, 200)
const feature = await sf.getFeatureDoc('c1', 'pt1', 'shop')
const id = feature.$id
const setze = (daten) => updateDocument(config.collections.shopConfig, id, daten)
await setze({ anfrageStatus: 'abgelehnt', anfrageEntschiedenAt: new Date().toISOString(), anfrageEntschiedenVon: 'Andrej <andrej@webklar.com>', anfrageBegruendung: 'Alkohol ist ausgeschlossen.' })
const abgelehnt = await status()
assert.equal(abgelehnt.body.anfrage.status, 'abgelehnt')
assert.equal(abgelehnt.body.anfrage.begruendung, 'Alkohol ist ausgeschlossen.')
assert.match(abgelehnt.body.anfrage.entschiedenAt, /^\d{4}-\d\d-\d\dT/)
assert.equal((await zurueck()).status, 409, 'abgelehnt laesst sich nicht zurueckziehen')
const neu = await stelle()
assert.equal(neu.status, 200)
assert.equal(neu.body.anfrage.begruendung, '')
assert.equal(neu.body.anfrage.entschiedenAt, null)
assert.equal(aw.docs('shopConfig').get(id).anfrageEntschiedenVon, '')
await setze({ anfrageStatus: 'angenommen' })
const angenommen = await stelle()
assert.equal(angenommen.status, 409)
assert.equal(angenommen.body.code, 'anfrage_laeuft')
assert.equal((await zurueck()).status, 409)
// Funktion nicht mehr off (bestellt): keine neue Anfrage, auch wenn der Status es erlauben wuerde
await setze({ anfrageStatus: 'abgelehnt' })
await updateDocument(config.collections.portalFeatures, id, { state: 'ordered' })
const bestellt = await stelle()
assert.equal(bestellt.status, 409)
assert.equal(bestellt.body.code, 'vorhanden')
assert.equal(bestellt.body.error, 'Für dieses Projekt gibt es schon einen Webshop.')
assert.equal((await status()).body.state, 'ordered')
})
test('Mailfehler: Anfrage ist trotzdem gespeichert (200), Fehler nur im Log', async () => {
await leereAlles()
mails.versandFehler = true
const warnungen = []
const warn = console.warn
console.warn = (...a) => warnungen.push(a.join(' '))
const error = console.error
console.error = () => {}
try {
const res = await stelle()
assert.equal(res.status, 200)
assert.equal(res.body.anfrage.status, 'offen')
} finally {
console.warn = warn
console.error = error
}
assert.equal(t21().length, 0)
assert.ok(warnungen.some((w) => w.includes('T21')), 'Log nennt T21')
assert.ok(!warnungen.some((w) => w.includes('k@example.invalid') || w.includes('Handgemachte')), 'keine Kundendaten im Log')
assert.equal([...aw.docs('shopConfig').values()][0].anfrageStatus, 'offen')
})
test('gleichzeitige Anfragen: genau eine gewinnt, eine Mail, ein shopConfig', async () => {
await leereAlles()
const antworten = await Promise.all([stelle(), stelle(), stelle()])
assert.deepEqual(antworten.map((a) => a.status).sort(), [200, 409, 409])
assert.equal(aw.docs('shopConfig').size, 1)
assert.equal(t21().length, 1)
})
test('ensureShopConfig: legt an, gleichzeitige Aufrufe teilen sich das Dokument (409 -> lesen)', async () => {
await leereAlles()
const feature = await sf.ensureFeatureDoc('c1', 'pt2', 'shop')
const [a, b, c] = await Promise.all([anfrage.ensureShopConfig(feature), anfrage.ensureShopConfig(feature), anfrage.ensureShopConfig(feature)])
assert.equal(a.$id, feature.$id)
assert.deepEqual([b.$id, c.$id], [feature.$id, feature.$id])
assert.equal(aw.docs('shopConfig').size, 1)
const doc = aw.docs('shopConfig').get(feature.$id)
assert.deepEqual([doc.customerId, doc.projectId, doc.siteKey, doc.anfrageStatus, doc.version], ['c1', 'pt2', feature.siteKey, '', 0])
assert.equal(anfrage.anfrageAnsicht(doc), null, 'ohne Anfrage keine Ansicht')
// vorhandenes Dokument bleibt unveraendert
await updateDocument(config.collections.shopConfig, feature.$id, { version: 7 })
assert.equal((await anfrage.ensureShopConfig(feature)).version, 7)
})
test('anfrageAnsicht: unlesbares JSON gibt anfrage null', () => {
assert.deepEqual(anfrage.anfrageAnsicht({ anfrageStatus: 'offen', anfrage: '{kaputt', anfrageAt: '2026-10-05T10:00:00.000+00:00' }), {
status: 'offen',
anfrage: null,
at: '2026-10-05T10:00:00.000+00:00',
entschiedenAt: null,
begruendung: '',
})
assert.equal(anfrage.anfrageAnsicht(null), null)
})
test('anfrageOffenePunkte (Haken bestellpruefung): nur angenommen gibt []', async () => {
await leereAlles()
const punkte = () => featureHaken('shop').bestellpruefung({ customerId: 'c1', projectId: 'pt1' })
assert.deepEqual(await punkte(), [anfrage.ANFRAGE_OFFEN_TEXT], 'ohne Dokumente')
assert.equal((await stelle()).status, 200)
assert.deepEqual(await punkte(), [anfrage.ANFRAGE_OFFEN_TEXT], 'offen')
const feature = await sf.getFeatureDoc('c1', 'pt1', 'shop')
await updateDocument(config.collections.shopConfig, feature.$id, { anfrageStatus: 'angenommen' })
assert.deepEqual(await punkte(), [])
assert.deepEqual(await anfrage.anfrageOffenePunkte({ customerId: 'c2', projectId: 'pt1' }), [anfrage.ANFRAGE_OFFEN_TEXT], 'fremder Kunde')
})
test('Rate-Limit: 20 Schreibaufrufe je Stunde und Kunde, dann 429; GET zaehlt nicht; anderer Kunde frei', async () => {
await leereAlles()
for (let i = 0; i < 20; i += 1) assert.equal((await stelle({ projectId: 'gibtsnicht' })).status, 403, `Aufruf ${i + 1}`)
const zuViele = await stelle({ projectId: 'gibtsnicht' })
assert.equal(zuViele.status, 429)
assert.match(zuViele.body.error, /Zu viele/)
assert.equal((await zurueck()).status, 429, 'zurueckziehen zaehlt mit')
assert.equal((await status()).status, 200)
assert.equal((await stelle({ projectId: 'pt3' }, { cookie: customerCookie('c2') })).status, 409, 'c2: nicht im Pilot, aber nicht begrenzt')
})
// Kundenansicht: die Schreib-Routen stehen nicht auf der Erlaubnisliste des Waechters; der Router sperrt zusaetzlich selbst
test('Kundenansicht: POSTs gesperrt (Waechter view_blocked), GET /status erlaubt', async () => {
await leereAlles()
const kv = { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } }
const a = await stelle({}, kv)
assert.equal(a.status, 403)
assert.equal(a.body.code, 'view_blocked')
const z = await zurueck('pt1', kv)
assert.equal(z.status, 403)
assert.equal(z.body.code, 'view_blocked')
const lesen = await status('pt1', kv)
assert.equal(lesen.status, 200)
assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0)
assert.equal(t21().length, 0)
})
test('Kundenansicht: der Router sperrt selbst, auch wenn der Waechter fehlt (403 mit eigenem Text)', async () => {
await leereAlles()
const express = (await import('express')).default
const { sessionMiddleware } = await import('../server/middleware/session.js')
const roh = express()
roh.use(sessionMiddleware())
roh.use(express.json())
roh.use('/api/shop', shopRoutes)
const server = await new Promise((resolve) => {
const s = roh.listen(0, '127.0.0.1', () => resolve(s))
})
try {
const url = `http://127.0.0.1:${server.address().port}`
for (const [pfad, body] of [['/api/shop/anfrage', { projectId: 'pt1', anfrage: GUELTIG }], ['/api/shop/anfrage/zurueckziehen', { projectId: 'pt1' }]]) {
for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) {
const res = await fetch(url + pfad, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify(body) })
assert.equal(res.status, 403, pfad)
assert.equal((await res.json()).error, 'In der Kundenansicht kann das nicht geändert werden.')
}
}
} finally {
await new Promise((resolve) => server.close(resolve))
}
assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0)
})
test('normaler Kunde im Pilot wird vom Waechter nicht beruehrt (Gegenprobe)', async () => {
await leereAlles()
const res = await stelle()
assert.equal(res.status, 200)
assert.equal((await get('/api/shop/status?projectId=pt1', { cookie })).anfrage.status, 'offen')
})
+612
View File
@@ -0,0 +1,612 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Webshop E3: Haendler-Aktionen (setzeStatus), Erstattung (erstatte), Liste, Detail, CSV und die Kundenrouten /api/shop/bestellungen*, /widerrufe*
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
termineOutbox: schemaKeys('termineOutbox'),
customerViewLog: schemaKeys('customerViewLog'),
},
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const K = await import('../server/services/shop/kontext.js')
const B = await import('../server/services/shop/bestellungen.js')
const sf = await import('../server/services/siteFeatures.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js')
const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes })
after(async () => {
setMailTransportForTests(null)
await Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Brotladen', archived: false })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' })
const KUNDE = 'c1'
const PROJEKT = 'pt1'
const ACCOUNT = 'acct_haendler1'
const T0 = new Date('2026-10-05T12:00:00.000Z')
const nach = (s) => new Date(T0.getTime() + s * 1000)
const cookie = customerCookie('c1')
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
kleinunternehmer: true,
lieferzeit: '2–4 Werktage',
shopname: 'Brot & Mehr',
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' },
abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' },
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null },
zahlungsarten: ['card'],
}
const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' }
const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN)
let sink
let log
let ctx
let feature
let shopId
let zaehler = 0
async function aufbau(state = 'active') {
for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'termineOutbox', 'customerViewLog']) {
for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id)
}
feature = await sf.ensureFeatureDoc(KUNDE, PROJEKT, 'shop')
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields(state))
feature = await sf.getFeatureDoc(KUNDE, PROJEKT, 'shop')
shopId = feature.$id
aw.seed('shopConfig', {
$id: shopId,
customerId: KUNDE,
projectId: PROJEKT,
siteKey: feature.siteKey,
anfrageStatus: 'angenommen',
stripeAccountId: ACCOUNT,
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }),
einstellungen: JSON.stringify(EINSTELLUNGEN),
rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }),
geoeffnet: true,
version: 1,
})
st.refunds(ACCOUNT).clear()
st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
K.leereCache()
ctx = await K.shopPerSiteKey(feature.siteKey, { now: T0 })
assert.ok(ctx, 'Shop-Kontext')
return ctx
}
beforeEach(async () => {
st.calls.length = 0
config.publicBaseUrl = 'https://project.webklar.com'
config.smtp.from = 'noreply@webklar.com'
config.shop.mode = 'pilot'
config.shop.pilotEmails = [customers.c1.email]
resetShopRoutesForTests()
resetOutboxForTests()
sink = mailSink()
setMailTransportForTests(sink)
log = { warn: mock.method(console, 'warn', () => {}), error: mock.method(console, 'error', () => {}) }
await aufbau()
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
})
const order = (id) => aw.docs('shopOrders').get(id)
const daten = (id) => JSON.parse(order(id).bestellung)
const eintraege = () => [...aw.docs('termineOutbox').values()]
const refunds = () => [...st.refunds(ACCOUNT).values()]
const POS = [{ produktId: 'a1', varianteId: null, name: 'Brot', varianteText: '', menge: 2, einzelCent: 600, summeCent: 1200, steuersatz: 0, widerruf: 'gesetzlich' }]
/** Bestellung direkt in die Ablage legen (Zustand wie nach schliesseAb). */
function bestellung(over = {}, datenOver = {}) {
zaehler += 1
const id = over.$id ?? `o${String(zaehler).padStart(3, '0')}`
const lieferart = datenOver.lieferart ?? 'versand'
const summeCent = over.summeCent ?? 1690
const d = {
positionen: POS,
summen: { zwischensummeCent: 1200, versandCent: 490, gesamtCent: summeCent, steuer: null, texte: { gesamt: '16,90 €' } },
lieferart,
land: 'DE',
anbieter: EINSTELLUNGEN.anbieter,
shopname: 'Brot & Mehr',
lieferzeit: '2–4 Werktage',
zahlungsarten: ['card'],
kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '+49 170 1234567' }, // alter Datensatz mit Telefonnummer: wird nie ausgegeben
lieferadresse: lieferart === 'versand' ? { name: 'Max Mustermann', line1: 'Lieferweg 7', line2: '', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } : undefined,
zahlungsart: 'card',
verlauf: [{ at: T0.toISOString(), was: 'angelegt' }, { at: nach(60).toISOString(), was: 'bezahlt' }],
...(lieferart === 'abholung' ? { abholung: { adresse: EINSTELLUNGEN.abholung.adresse, hinweis: EINSTELLUNGEN.abholung.hinweis } } : {}),
...datenOver,
}
aw.seed('shopOrders', {
$id: id,
shopId,
customerId: KUNDE,
bestellnummer: `26-${String(zaehler).padStart(6, 'A')}`,
status: 'bezahlt',
tokenHash: 'h'.repeat(64),
stripeSessionId: `cs_${id}`,
paymentIntentId: `pi_${id}`,
summeCent,
erstelltAt: nach(zaehler).toISOString(),
ablaufAt: nach(1800).toISOString(),
bezahltAt: nach(60 + zaehler).toISOString(),
kaeuferEmail: 'max@example.com',
bestellung: JSON.stringify(d),
...over,
})
st.setPaymentIntent(ACCOUNT, `pi_${id}`, { amount: summeCent })
return id
}
// ------------------------------------------------------------------------------------------------ setzeStatus
test('versendet: aus bezahlt, Verlauf mit Dienst und Sendungsnummer, G10 an den Käufer', async () => {
const id = bestellung()
const erg = await B.setzeStatus(ctx, id, 'versendet', { versanddienst: ' DHL ', sendungsnummer: 'JJD0123 456' }, { now: nach(600) })
assert.equal(erg.order.status, 'versendet')
assert.equal(erg.mailOk, true)
assert.equal(order(id).status, 'versendet')
const v = daten(id).verlauf.at(-1)
assert.deepEqual(v, { at: nach(600).toISOString(), was: 'versendet', versanddienst: 'DHL', sendungsnummer: 'JJD0123 456' })
assert.equal(daten(id).versand.sendungsnummer, 'JJD0123 456')
await warteAufVersand()
assert.deepEqual(eintraege().map((e) => e.art), ['G10'])
const mail = sink.sent.find((m) => m.to[0].address === 'max@example.com')
assert.ok(mail.text.includes('JJD0123 456') && mail.text.includes('DHL'))
// zweiter Aufruf: 409
await assert.rejects(B.setzeStatus(ctx, id, 'versendet', {}, { now: nach(700) }), (e) => e.status === 409 && /schon als versendet/.test(e.message))
assert.equal(eintraege().length, 1)
})
test('versendet: Eingaben prüfen (Länge, Typ), nur bei Versand; abholbereit nur bei Abholung (G11)', async () => {
const v = bestellung()
await assert.rejects(B.setzeStatus(ctx, v, 'versendet', { versanddienst: 'x'.repeat(61) }), (e) => e.status === 400)
await assert.rejects(B.setzeStatus(ctx, v, 'versendet', { sendungsnummer: 'x'.repeat(81) }), (e) => e.status === 400)
await assert.rejects(B.setzeStatus(ctx, v, 'versendet', { sendungsnummer: 5 }), (e) => e.status === 400)
assert.equal(order(v).status, 'bezahlt')
await assert.rejects(B.setzeStatus(ctx, v, 'abholbereit'), (e) => e.status === 409 && /versendet/.test(e.message))
const a = bestellung({}, { lieferart: 'abholung' })
await assert.rejects(B.setzeStatus(ctx, a, 'versendet'), (e) => e.status === 409 && /abgeholt/.test(e.message))
const erg = await B.setzeStatus(ctx, a, 'abholbereit', {}, { now: nach(30) })
assert.equal(erg.order.status, 'abholbereit')
await warteAufVersand()
assert.deepEqual(eintraege().map((e) => e.art), ['G11'])
assert.ok(sink.sent.find((m) => m.to[0].address === 'max@example.com').text.includes('Mo–Fr 8–12 Uhr'))
await assert.rejects(B.setzeStatus(ctx, a, 'abholbereit'), (e) => e.status === 409)
})
test('abgeschlossen: aus bezahlt, versendet und abholbereit; nicht aus offen, erstattet, storniert; keine Mail', async () => {
const a = bestellung()
const b = bestellung({ status: 'versendet' })
const c = bestellung({ status: 'abholbereit' }, { lieferart: 'abholung' })
for (const id of [a, b, c]) assert.equal((await B.setzeStatus(ctx, id, 'abgeschlossen', {}, { now: nach(10) })).order.status, 'abgeschlossen')
assert.equal(daten(a).verlauf.at(-1).was, 'abgeschlossen')
for (const status of ['offen', 'erstattet', 'storniert', 'abgeschlossen']) {
const id = bestellung({ status })
await assert.rejects(B.setzeStatus(ctx, id, 'abgeschlossen'), (e) => e.status === 409, status)
}
await warteAufVersand()
assert.equal(eintraege().length, 0)
await assert.rejects(B.setzeStatus(ctx, a, 'quatsch'), (e) => e.status === 400)
})
test('storniert: nur aus offen - beendet die Kasse bei Stripe und verwirft; bezahlte Bestellung 409', async () => {
const id = bestellung({ status: 'offen', stripeSessionId: 'cs_offen1' })
st.accountSessions(ACCOUNT).set('cs_offen1', { id: 'cs_offen1', object: 'checkout.session', status: 'open', payment_status: 'unpaid' })
const erg = await B.setzeStatus(ctx, id, 'storniert', {}, { now: nach(100) })
assert.equal(erg.order.status, 'storniert')
assert.equal(st.accountSessions(ACCOUNT).get('cs_offen1').status, 'expired')
assert.equal(daten(id).verlauf.at(-1).was, 'vom_haendler')
const bezahlt = bestellung()
await assert.rejects(B.setzeStatus(ctx, bezahlt, 'storniert'), (e) => e.status === 409 && /noch nicht|nicht mehr/.test(e.message))
assert.equal(order(bezahlt).status, 'bezahlt')
})
test('fremde und unbekannte Bestellungen: 404, nichts verändert; auch per Shop-ID statt Kontext', async () => {
const fremd = bestellung({ shopId: 'f_anderer_shop' })
await assert.rejects(B.setzeStatus(ctx, fremd, 'abgeschlossen'), (e) => e.status === 404)
await assert.rejects(B.erstatte(ctx, fremd, {}), (e) => e.status === 404)
await assert.rejects(B.setzeStatus(ctx, 'gibtsnicht', 'abgeschlossen'), (e) => e.status === 404)
await assert.rejects(B.setzeStatus(ctx, '../x', 'abgeschlossen'), (e) => e.status === 404)
assert.equal(order(fremd).status, 'bezahlt')
assert.equal(refunds().length, 0)
const eigene = bestellung()
assert.equal((await B.setzeStatus(shopId, eigene, 'abgeschlossen')).order.status, 'abgeschlossen')
})
// ------------------------------------------------------------------------------------------------ erstatte
test('erstatte: Teilerstattung behält den Status, Restbetrag zählt, dann Rest voll => erstattet; G13 je Erstattung', async () => {
const id = bestellung({ status: 'versendet' })
const t = await B.erstatte(ctx, id, { betragCent: 500, grund: 'Brot war hart' }, { now: nach(100) })
assert.equal(t.voll, false)
assert.equal(t.order.status, 'versendet')
assert.equal(t.mailOk, true)
const r1 = refunds()
assert.equal(r1.length, 1)
assert.equal(r1[0].amount, 500)
assert.equal(r1[0].payment_intent, `pi_${id}`)
assert.deepEqual(daten(id).erstattungen, [{ rid: r1[0].id, betragCent: 500, grund: 'Brot war hart', at: nach(100).toISOString(), quelle: 'portal' }])
assert.equal(order(id).status, 'versendet')
assert.equal(daten(id).verlauf.at(-1).was, 'teilerstattet')
await assert.rejects(B.erstatte(ctx, id, { betragCent: 1191 }), (e) => e.status === 400 && /11,90/.test(e.message))
const rest = await B.erstatte(ctx, id, {}, { now: nach(200) })
assert.equal(rest.voll, true)
assert.equal(rest.erstattung.betragCent, 1190)
assert.equal(order(id).status, 'erstattet')
assert.equal(refunds().length, 2)
assert.equal(daten(id).erstattungen.length, 2)
// erstattet: nichts mehr zu holen
await assert.rejects(B.erstatte(ctx, id, {}), (e) => e.status === 409)
await warteAufVersand()
assert.deepEqual(eintraege().map((e) => e.art), ['G13', 'G13'])
const texte = sink.sent.filter((m) => m.to[0].address === 'max@example.com').map((m) => m.text)
assert.ok(texte.some((t) => t.includes('5,00') && t.includes('Brot war hart')))
// Idempotenz-Schluessel je Zaehlerstand, Aufruf im Namen des Haendlerkontos
const aufrufe = st.calls.filter((c) => c.path === '/v1/refunds')
assert.deepEqual(aufrufe.map((c) => c.idempotencyKey), [`shop-erstattung-${id}-0`, `shop-erstattung-${id}-1`])
assert.ok(aufrufe.every((c) => c.account === ACCOUNT))
})
test('erstatte: Eingaben und Zustände - Betrag ganze Cent 1..Rest, Grund ≤ 300, unbezahlt/storniert/ohne Zahlung 409', async () => {
const id = bestellung()
for (const betragCent of [0, -5, 1.5, 1691, '500']) {
await assert.rejects(B.erstatte(ctx, id, { betragCent }), (e) => e.status === 400, String(betragCent))
}
await assert.rejects(B.erstatte(ctx, id, { grund: 'x'.repeat(301) }), (e) => e.status === 400)
await assert.rejects(B.erstatte(ctx, id, { grund: 42 }), (e) => e.status === 400)
assert.equal(refunds().length, 0)
for (const status of ['offen', 'storniert']) {
await assert.rejects(B.erstatte(ctx, bestellung({ status }), {}), (e) => e.status === 409, status)
}
await assert.rejects(B.erstatte(ctx, bestellung({ paymentIntentId: '' }), {}), (e) => e.status === 409 && e.code === 'keine_zahlung')
assert.equal(refunds().length, 0)
// Ohne Betrag: der ganze Rest; Grund ganz weglassen geht
const voll = await B.erstatte(ctx, id, {}, { now: nach(5) })
assert.equal(voll.erstattung.betragCent, 1690)
assert.equal(voll.erstattung.grund, '')
assert.equal(order(id).status, 'erstattet')
})
test('erstatte: Erstattungen aus dem Stripe-Dashboard (quelle stripe) zählen zum Rest; gleiche rid wird nicht doppelt eingetragen', async () => {
const id = bestellung({}, { erstattungen: [{ rid: 're_dash', betragCent: 1000, grund: 'Stripe-Dashboard', at: nach(5).toISOString(), quelle: 'stripe' }] })
await assert.rejects(B.erstatte(ctx, id, { betragCent: 691 }), (e) => e.status === 400 && /6,90/.test(e.message))
// Stripe kennt die Dashboard-Erstattung ebenfalls (sonst waere der Rest dort groesser)
st.refunds(ACCOUNT).set('re_dash', { id: 're_dash', amount: 1000, payment_intent: `pi_${id}` })
const erg = await B.erstatte(ctx, id, {}, { now: nach(50) })
assert.equal(erg.erstattung.betragCent, 690)
assert.equal(erg.voll, true)
assert.deepEqual(daten(id).erstattungen.map((e) => e.quelle), ['stripe', 'portal'])
assert.equal(order(id).status, 'erstattet')
})
test('erstatte: zwei gleichzeitige Voll-Erstattungen - genau eine geht an Stripe, die andere 409', async () => {
const id = bestellung()
const ergebnisse = await Promise.allSettled([B.erstatte(ctx, id, {}, { now: nach(1) }), B.erstatte(ctx, id, {}, { now: nach(2) }), B.erstatte(ctx, id, {}, { now: nach(3) })])
assert.equal(ergebnisse.filter((e) => e.status === 'fulfilled').length, 1)
for (const e of ergebnisse.filter((x) => x.status === 'rejected')) assert.equal(e.reason.status, 409)
assert.equal(refunds().length, 1)
assert.equal(daten(id).erstattungen.length, 1)
await warteAufVersand()
assert.equal(eintraege().filter((e) => e.art === 'G13').length, 1)
})
test('erstatte: zwei gleichzeitige Teil-Erstattungen, die zusammen zu viel wären - die zweite wird abgelehnt', async () => {
const id = bestellung()
const ergebnisse = await Promise.allSettled([B.erstatte(ctx, id, { betragCent: 1000 }), B.erstatte(ctx, id, { betragCent: 1000 })])
assert.equal(ergebnisse.filter((e) => e.status === 'fulfilled').length, 1)
assert.equal(ergebnisse.find((e) => e.status === 'rejected').reason.status, 400)
assert.equal(refunds().length, 1)
})
test('erstatte: Stripe-Fehler => 502 mit festem Text ohne Stripe-Wortlaut, nichts gespeichert, keine Mail', async () => {
const id = bestellung()
st.setPaymentIntent(ACCOUNT, `pi_${id}`, { amount: 500 }) // Stripe kennt nur 5 € Rest => Betrag zu gross
const err = await B.erstatte(ctx, id, { betragCent: 1000, grund: 'x' }).catch((e) => e)
assert.equal(err.status, 502)
assert.equal(err.message, 'Die Erstattung hat bei Stripe nicht geklappt. Bitte versuch es noch einmal oder erstatte direkt im Stripe-Dashboard.')
assert.doesNotMatch(err.message, /greater|amount/i)
assert.equal(daten(id).erstattungen, undefined)
assert.equal(order(id).status, 'bezahlt')
await warteAufVersand()
assert.equal(eintraege().length, 0)
// danach geht es mit passendem Betrag wieder
const ok = await B.erstatte(ctx, id, { betragCent: 500 })
assert.equal(ok.erstattung.betragCent, 500)
})
// ------------------------------------------------------------------------------------------------ Liste und Detail
test('listeBestellungen: neueste zuerst, ohne Filter nur bezahlte, Statusfilter, Suche (Nummer-Anfang, Name, E-Mail), Blättern mit 50 je Seite', async () => {
const ids = []
for (let i = 0; i < 55; i += 1) ids.push(bestellung({ kaeuferEmail: `k${i}@example.com` }, { kaeufer: { name: i === 3 ? 'Erika Beispiel' : `Kunde ${i}`, email: `k${i}@example.com` } }))
const offen = bestellung({ status: 'offen' })
const storniert = bestellung({ status: 'storniert' })
const versendet = bestellung({ status: 'versendet' })
const s1 = await B.listeBestellungen(shopId, {})
assert.equal(s1.bestellungen.length, 50)
assert.equal(s1.mehr, true)
assert.equal(s1.bestellungen[0].id, versendet, 'zuletzt angelegt zuerst')
assert.ok(!s1.bestellungen.some((b) => [offen, storniert].includes(b.id)))
const s2 = await B.listeBestellungen(shopId, { seite: 2 })
assert.equal(s2.bestellungen.length, 6)
assert.equal(s2.mehr, false)
assert.equal(new Set([...s1.bestellungen, ...s2.bestellungen].map((b) => b.id)).size, 56)
assert.deepEqual((await B.listeBestellungen(shopId, { status: 'offen' })).bestellungen.map((b) => b.id), [offen])
assert.deepEqual((await B.listeBestellungen(shopId, { status: 'versendet' })).bestellungen.map((b) => b.id), [versendet])
await assert.rejects(B.listeBestellungen(shopId, { status: 'quatsch' }), (e) => e.status === 400)
const name = await B.listeBestellungen(shopId, { suche: 'erika' })
assert.deepEqual(name.bestellungen.map((b) => [b.id, b.kaeuferName]), [[ids[3], 'Erika Beispiel']])
const nr = await B.listeBestellungen(shopId, { suche: order(ids[7]).bestellnummer.toLowerCase() })
assert.deepEqual(nr.bestellungen.map((b) => b.id), [ids[7]])
assert.equal((await B.listeBestellungen(shopId, { suche: 'k12@' })).bestellungen.length, 1)
assert.equal((await B.listeBestellungen(shopId, { suche: 'gibtsnicht' })).bestellungen.length, 0)
// andere Shops sind unsichtbar
assert.equal((await B.listeBestellungen('f_anderer', {})).bestellungen.length, 0)
const zeile = s1.bestellungen[0]
assert.deepEqual(Object.keys(zeile).sort(), ['anzahlArtikel', 'bestellnummer', 'bezahltAt', 'erstattetCent', 'erstelltAt', 'id', 'kaeuferEmail', 'kaeuferName', 'lieferart', 'spaet', 'status', 'summeCent', 'summeText', 'ueberverkauft'])
})
test('bestellungKunde: alle Händlerdaten, keine Token-/Stripe-Kennungen, mögliche Aktionen und Rest', async () => {
const id = bestellung({}, { erstattungen: [{ rid: 're_1', betragCent: 190, grund: 'Kulanz', at: nach(9).toISOString(), quelle: 'portal' }], ueberverkauft: true })
const ansicht = B.bestellungKunde(await B.bestellungLesen(id))
assert.equal(ansicht.kaeufer.email, 'max@example.com')
assert.deepEqual(ansicht.kaeufer, { name: 'Max Mustermann', email: 'max@example.com' }, 'eine alte Telefonnummer im Datensatz wird nicht ausgegeben')
assert.ok(!JSON.stringify(ansicht).includes('1234567'))
assert.equal(ansicht.lieferadresse.line1, 'Lieferweg 7')
assert.equal(ansicht.erstattetCent, 190)
assert.equal(ansicht.restCent, 1500)
assert.deepEqual(ansicht.aktionen, ['versendet', 'abgeschlossen'])
assert.equal(ansicht.kannErstatten, true)
assert.equal(ansicht.ueberverkauft, true)
assert.equal(ansicht.verlauf.length, 2)
assert.equal(ansicht.positionen[0].name, 'Brot')
const text = JSON.stringify(ansicht)
assert.ok(!text.includes('hhhhhh') && !text.includes('cs_') && !text.includes('pi_'))
assert.deepEqual(B.bestellungKunde(await B.bestellungLesen(bestellung({ status: 'abholbereit' }, { lieferart: 'abholung' }))).aktionen, ['abgeschlossen'])
assert.deepEqual(B.bestellungKunde(await B.bestellungLesen(bestellung({ status: 'erstattet' }))).aktionen, [])
})
// ------------------------------------------------------------------------------------------------ CSV
test('csv: BOM, Semikolon, Kopfzeile, Beträge ohne NBSP, Berliner Datum, Formel-Injektion entschärft, Anführungszeichen verdoppelt', async () => {
const a = bestellung({ bezahltAt: '2026-10-04T22:30:00.000Z', summeCent: 1250, kaeuferEmail: '+evil@example.com' }, { kaeufer: { name: '=HYPERLINK("http://x.example";"a")', email: '+evil@example.com' } })
const b = bestellung(
{ bezahltAt: '2026-10-06T08:00:00.000Z', status: 'erstattet', kaeuferEmail: 'anna@example.com' },
{ kaeufer: { name: 'Anna; "Annie" Schmidt', email: 'anna@example.com' }, erstattungen: [{ rid: 're_x', betragCent: 1690, grund: '', at: T0.toISOString(), quelle: 'portal' }] }
)
bestellung({ status: 'offen' })
bestellung({ status: 'storniert' })
const erg = await B.csv(shopId, {}, { now: nach(0) })
assert.ok(erg.csv.startsWith('\uFEFFBestellnummer;Datum;Status;Name;E-Mail;Lieferart;Adresse;Positionen;Versand;Gesamt;Erstattet\r\n'))
assert.equal(erg.anzahl, 2)
const zeilen = erg.csv.slice(1).trimEnd().split('\r\n')
assert.equal(zeilen.length, 3)
assert.equal(erg.dateiname, 'webshop-bestellungen-anfang-2026-10-05.csv')
assert.ok(zeilen[1].startsWith(`${order(a).bestellnummer};05.10.2026 00:30;Bezahlt;`), zeilen[1]) // 22:30 UTC = 00:30 Berlin am Folgetag
assert.ok(zeilen[1].includes(`;"'=HYPERLINK(""http://x.example"";""a"")";'+evil@example.com;Versand;Lieferweg 7, 67655 Kaiserslautern, DE;2 × Brot;4,90;12,50;0,00`), zeilen[1])
assert.ok(zeilen[2].includes(';Erstattet;"Anna; ""Annie"" Schmidt";anna@example.com;'), zeilen[2])
assert.ok(zeilen[2].endsWith(';4,90;16,90;16,90'), zeilen[2])
assert.doesNotMatch(erg.csv, /\u00a0|€/)
for (const z of zeilen.slice(1)) assert.doesNotMatch(z, /(^|;)[=+@]/)
assert.ok(order(b))
})
test('csv: Zeitraum von/bis (Berliner Kalendertage, inklusiv), Dateiname, ungültige Eingaben 400', async () => {
bestellung({ bezahltAt: '2026-10-04T21:59:00.000Z' }) // 04.10. 23:59 Berlin
const b = bestellung({ bezahltAt: '2026-10-04T22:00:00.000Z' }) // 05.10. 00:00 Berlin
const c = bestellung({ bezahltAt: '2026-10-05T21:59:00.000Z' }) // 05.10. 23:59 Berlin
bestellung({ bezahltAt: '2026-10-05T22:00:00.000Z' }) // 06.10. 00:00 Berlin
const erg = await B.csv(shopId, { von: '2026-10-05', bis: '2026-10-05' })
assert.equal(erg.dateiname, 'webshop-bestellungen-2026-10-05-2026-10-05.csv')
const nummern = erg.csv.split('\r\n').slice(1, -1).map((z) => z.split(';')[0])
assert.deepEqual(nummern, [order(b).bestellnummer, order(c).bestellnummer])
assert.equal((await B.csv(shopId, { von: '2026-10-06' })).anzahl, 1)
for (const eingabe of [{ von: '05.10.2026' }, { bis: '2026-02-30' }, { von: '2026-10-06', bis: '2026-10-05' }, { von: 5 }]) {
await assert.rejects(B.csv(shopId, eingabe), (e) => e.status === 400, JSON.stringify(eingabe))
}
assert.equal((await B.csv('f_anderer', {})).anzahl, 0)
})
// ------------------------------------------------------------------------------------------------ Routen
const lesen = (pfad, projectId = PROJEKT, opts = { cookie }) => send('GET', `/api/shop/${pfad}${pfad.includes('?') ? '&' : '?'}projectId=${projectId}`, opts)
const schreiben = (pfad, body = {}, opts = { cookie }) => send('POST', `/api/shop/${pfad}`, { ...opts, body: { projectId: PROJEKT, ...body } })
test('Routen: Liste, Detail, Aktion, Erstatten - Antworten ohne Token und Stripe-Kennungen', async () => {
const id = bestellung()
bestellung({ status: 'versendet' })
const liste = await lesen('bestellungen')
assert.equal(liste.status, 200)
assert.equal(liste.body.bestellungen.length, 2)
assert.deepEqual([liste.body.seite, liste.body.mehr], [1, false])
assert.equal((await lesen('bestellungen?status=versendet')).body.bestellungen.length, 1)
assert.equal((await lesen('bestellungen?status=quatsch')).status, 400)
assert.equal((await lesen('bestellungen?suche=max')).body.bestellungen.length, 2)
const detail = await lesen(`bestellungen/${id}`)
assert.equal(detail.status, 200)
assert.equal(detail.body.bestellung.kaeufer.name, 'Max Mustermann')
assert.ok(!JSON.stringify(detail.body).includes('tokenHash') && !JSON.stringify(detail.body).includes('pi_'))
const versand = await schreiben(`bestellungen/${id}/aktion`, { aktion: 'versendet', versanddienst: 'DHL', sendungsnummer: 'ABC123' })
assert.equal(versand.status, 200)
assert.equal(versand.body.bestellung.status, 'versendet')
assert.equal(versand.body.mailOk, true)
const nochmal = await schreiben(`bestellungen/${id}/aktion`, { aktion: 'versendet' })
assert.deepEqual([nochmal.status, nochmal.body.code], [409, 'falscher_status'])
assert.equal((await schreiben(`bestellungen/${id}/aktion`, { aktion: 'quatsch' })).status, 400)
assert.equal((await schreiben(`bestellungen/${id}/aktion`, {})).status, 400)
const teil = await schreiben(`bestellungen/${id}/erstatten`, { betragCent: 300, grund: 'Kulanz' })
assert.equal(teil.status, 200)
assert.deepEqual([teil.body.voll, teil.body.bestellung.erstattetCent, teil.body.bestellung.status], [false, 300, 'versendet'])
assert.equal(teil.body.erstattung.quelle, 'portal')
const zuViel = await schreiben(`bestellungen/${id}/erstatten`, { betragCent: 99999 })
assert.equal(zuViel.status, 400)
const rest = await schreiben(`bestellungen/${id}/erstatten`, {})
assert.deepEqual([rest.status, rest.body.voll, rest.body.bestellung.status], [200, true, 'erstattet'])
// Stripe-Fehler: 502, fester Text
const kaputt = bestellung()
st.setPaymentIntent(ACCOUNT, `pi_${kaputt}`, { amount: 100 })
const fehler = await schreiben(`bestellungen/${kaputt}/erstatten`, { betragCent: 500 })
assert.equal(fehler.status, 502)
assert.match(fehler.body.error, /^Die Erstattung hat bei Stripe nicht geklappt\./)
})
test('Routen: parallele Erstattungen über HTTP erstatten nie doppelt', async () => {
const id = bestellung()
const antworten = await Promise.all([1, 2, 3].map(() => schreiben(`bestellungen/${id}/erstatten`, {})))
assert.deepEqual(antworten.map((a) => a.status).sort(), [200, 409, 409])
assert.equal(refunds().length, 1)
})
test('Routen: fremde Bestellung 404, fremdes Projekt 403, ohne Session 401, nicht eingerichtet 409', async () => {
const fremd = bestellung({ shopId: 'f_anderer_shop' })
assert.equal((await lesen(`bestellungen/${fremd}`)).status, 404)
assert.equal((await schreiben(`bestellungen/${fremd}/aktion`, { aktion: 'abgeschlossen' })).status, 404)
assert.equal((await schreiben(`bestellungen/${fremd}/erstatten`, {})).status, 404)
assert.equal((await lesen('bestellungen/nix')).status, 404)
assert.equal(order(fremd).status, 'bezahlt')
assert.equal(refunds().length, 0)
assert.equal((await lesen('bestellungen', 'pt3')).status, 403)
assert.equal((await send('GET', `/api/shop/bestellungen?projectId=${PROJEKT}`)).status, 401)
assert.equal((await send('POST', '/api/shop/bestellungen/x/aktion', { body: { projectId: PROJEKT } })).status, 401)
config.shop.mode = 'off'
assert.equal((await lesen('bestellungen')).body.code, 'nicht_verfuegbar')
})
test('Routen: Kundenansicht darf lesen (Liste, Detail, CSV, Widerrufe), aber weder Status ändern noch erstatten (403)', async () => {
const id = bestellung()
const KV = { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } }
assert.equal((await lesen('bestellungen', PROJEKT, KV)).status, 200)
assert.equal((await lesen(`bestellungen/${id}`, PROJEKT, KV)).status, 200)
assert.equal((await lesen('bestellungen.csv', PROJEKT, KV)).status, 200)
assert.equal((await lesen('widerrufe', PROJEKT, KV)).status, 200)
for (const [pfad, body] of [[`bestellungen/${id}/aktion`, { aktion: 'abgeschlossen' }], [`bestellungen/${id}/erstatten`, {}], ['widerrufe/w1/erledigt', {}]]) {
const res = await schreiben(pfad, body, KV)
assert.equal(res.status, 403, pfad)
}
// Der Router selbst sperrt ebenfalls (ohne den vorgeschalteten Guard), auch bei "Ansehen als"
const express = (await import('express')).default
const { sessionMiddleware } = await import('../server/middleware/session.js')
const roh = express()
roh.use(sessionMiddleware())
roh.use(express.json())
roh.use('/api/shop', shopRoutes)
const server = await new Promise((resolve) => {
const s = roh.listen(0, '127.0.0.1', () => resolve(s))
})
try {
const url = `http://127.0.0.1:${server.address().port}`
for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) {
for (const pfad of [`bestellungen/${id}/aktion`, `bestellungen/${id}/erstatten`, 'widerrufe/w1/erledigt']) {
const res = await fetch(`${url}/api/shop/${pfad}`, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify({ projectId: PROJEKT, aktion: 'abgeschlossen' }) })
assert.equal(res.status, 403, pfad)
}
}
} finally {
await new Promise((resolve) => server.close(resolve))
}
assert.equal(order(id).status, 'bezahlt')
assert.equal(refunds().length, 0)
})
test('Routen: CSV als Download mit BOM, Dateiname und Zeitraum; ungültiges Datum 400', async () => {
bestellung({ bezahltAt: '2026-10-05T10:00:00.000Z' }, { kaeufer: { name: '@SUM(A1)', email: 'a@example.com' } })
bestellung({ bezahltAt: '2026-09-01T10:00:00.000Z' })
const res = await fetch(`${app.url}/api/shop/bestellungen.csv?projectId=${PROJEKT}&von=2026-10-01&bis=2026-10-31`, { headers: { cookie } })
assert.equal(res.status, 200)
assert.match(res.headers.get('content-type'), /^text\/csv; charset=utf-8/)
assert.equal(res.headers.get('content-disposition'), 'attachment; filename="webshop-bestellungen-2026-10-01-2026-10-31.csv"')
const bytes = Buffer.from(await res.arrayBuffer())
assert.deepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf], 'BOM')
const text = bytes.toString('utf8')
assert.ok(text.includes('Bestellnummer;Datum;'))
assert.equal(text.trimEnd().split('\r\n').length, 2)
assert.ok(text.includes(";'@SUM(A1);"))
assert.equal((await lesen('bestellungen.csv?von=morgen')).status, 400)
assert.equal((await send('GET', `/api/shop/bestellungen.csv?projectId=${PROJEKT}`)).status, 401)
})
test('Routen M2: deaktivierter Shop - Lesen, CSV, Status ändern, Erstatten und „Widerruf erledigt“ gehen weiter; Einstellungen und Artikel bleiben gesperrt', async () => {
const id = bestellung()
aw.seed('shopWiderrufe', { $id: 'w1', shopId, customerId: KUNDE, bestellnummer: '26-A', name: 'Alt', email: 'alt@example.com', nachricht: '', eingangAt: nach(1).toISOString() })
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('disabled'))
assert.equal((await lesen('bestellungen')).status, 200)
assert.equal((await lesen(`bestellungen/${id}`)).status, 200)
assert.equal((await lesen('bestellungen.csv')).status, 200)
assert.equal((await lesen('widerrufe')).status, 200)
const versand = await schreiben(`bestellungen/${id}/aktion`, { aktion: 'versendet' })
assert.equal(versand.status, 200)
assert.equal(order(id).status, 'versendet')
const teil = await schreiben(`bestellungen/${id}/erstatten`, { betragCent: 300 })
assert.deepEqual([teil.status, teil.body.voll], [200, false])
assert.equal(refunds().length, 1)
const rest = await schreiben(`bestellungen/${id}/erstatten`, {})
assert.deepEqual([rest.status, rest.body.voll], [200, true])
const erledigt = await schreiben('widerrufe/w1/erledigt')
assert.equal(erledigt.status, 200)
assert.ok(erledigt.body.widerruf.bearbeitetAt)
// Alles andere, was verwaltet oder geoeffnet wird, bleibt gesperrt
for (const [methode, pfad, body] of [['POST', 'produkte', { produkt: { name: 'X', preisCent: 100 } }], ['PUT', 'einstellungen', { einstellungen: {} }], ['POST', 'oeffnen', { offen: true }]]) {
const res = await send(methode, `/api/shop/${pfad}`, { cookie, body: { projectId: PROJEKT, ...body } })
assert.deepEqual([res.status, res.body.code], [409, 'nicht_eingerichtet'], pfad)
}
})
test('Routen: Widerrufe - Liste (neueste zuerst, nur eigener Shop) und als erledigt markieren', async () => {
aw.seed('shopWiderrufe', { $id: 'w_alt', shopId, customerId: KUNDE, bestellnummer: '26-A', name: 'Alt', email: 'alt@example.com', nachricht: '', eingangAt: nach(1).toISOString() })
aw.seed('shopWiderrufe', { $id: 'w_neu', shopId, customerId: KUNDE, bestellnummer: '26-B', name: 'Neu', email: 'neu@example.com', nachricht: 'Hallo', eingangAt: nach(99).toISOString() })
aw.seed('shopWiderrufe', { $id: 'w_fremd', shopId: 'f_anderer', customerId: 'c2', bestellnummer: '26-C', name: 'Fremd', email: 'f@example.com', nachricht: '', eingangAt: nach(200).toISOString() })
const liste = await lesen('widerrufe')
assert.equal(liste.status, 200)
assert.deepEqual(liste.body.widerrufe.map((w) => w.id), ['w_neu', 'w_alt'])
const erledigt = await schreiben('widerrufe/w_neu/erledigt')
assert.equal(erledigt.status, 200)
assert.ok(erledigt.body.widerruf.bearbeitetAt)
assert.equal((await schreiben('widerrufe/w_fremd/erledigt')).status, 404)
assert.equal(aw.docs('shopWiderrufe').get('w_fremd').bearbeitetAt ?? null, null)
})
+457
View File
@@ -0,0 +1,457 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Bestellung anlegen (E1): Berechnung nur aus der Datenbank, Reservierung unter Sperre, Stripe-Parameter auf dem Haendlerkonto,
// Rueck-URL-Pruefung, Fehlerwege (geschlossen, ausverkauft, Stripe-Fehler, Klarna nicht freigeschaltet).
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
},
datetimes: SHOP_DATETIMES,
})
const st = await startFakeStripe()
after(async () => {
await aw.close()
await st.close()
})
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const K = await import('../server/services/shop/kontext.js')
const B = await import('../server/services/shop/bestellungen.js')
const { normalisiereProdukt } = await import('../server/services/shop/produkte.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_shop'
const SITE_KEY = 'abcdefghijklmnop'
const ACCOUNT = 'acct_haendler1'
const T0 = new Date('2026-10-05T12:00:00.000Z')
const nach = (s) => new Date(T0.getTime() + s * 1000)
const PORTAL = 'https://project.webklar.com'
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
kleinunternehmer: true,
lieferzeit: '2–4 Werktage',
shopname: 'Brot & Mehr',
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' },
abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' },
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null },
zahlungsarten: ['card'],
}
const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' }
const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN)
const featureDoc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true }
const shopDoc = (over = {}, einstellungen = EINSTELLUNGEN) => ({
$id: FEATURE,
customerId: KUNDE,
projectId: PROJEKT,
siteKey: SITE_KEY,
anfrageStatus: 'angenommen',
stripeAccountId: ACCOUNT,
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }),
einstellungen: JSON.stringify(einstellungen),
rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }),
geoeffnet: true,
version: 1,
...over,
})
const artikel = (id, produktEingabe = {}) => {
const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...produktEingabe }, { einstellungen: { kleinunternehmer: true } })
assert.equal(r.fehler, undefined, JSON.stringify(r.fehler))
return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt) }
}
async function aufbau({ shop = {}, einstellungen = EINSTELLUNGEN, produkte = [artikel('a1')], projekt = {} } = {}) {
for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects']) {
for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id)
}
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'https://www.brot.example', ...projekt })
aw.seed('portalFeatures', featureDoc)
aw.seed('shopConfig', shopDoc(shop, einstellungen))
for (const p of produkte) aw.seed('shopProducts', p)
st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
K.leereCache()
return K.shopPerSiteKey(SITE_KEY, { now: T0 })
}
beforeEach(() => {
st.calls.length = 0
config.publicBaseUrl = PORTAL
K.leereCache()
})
const sessionCalls = () => st.calls.filter((c) => c.path === '/v1/checkout/sessions' && c.method === 'POST')
const orders = () => [...aw.docs('shopOrders').values()]
const eingabe = (over = {}) => ({ positionen: [{ produktId: 'a1', menge: 2 }], lieferart: 'versand', land: 'DE', rueckUrl: 'https://www.brot.example/laden?x=1#warenkorb', ...over })
const rejectsMit = (promise, status, code) =>
assert.rejects(promise, (err) => {
assert.equal(err.status, status, err.message)
assert.equal(err.code, code)
return true
})
test('neueBestellnummer: JJ-XXXXXX ohne 0, 1, O, I; Jahr nach Berliner Zeit', () => {
for (let i = 0; i < 200; i += 1) assert.match(B.neueBestellnummer(T0), /^26-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/)
// Silvester 23:30 UTC ist in Berlin schon Neujahr
assert.match(B.neueBestellnummer(new Date('2026-12-31T23:30:00.000Z')), /^27-/)
assert.match(B.neueBestellnummer(new Date('2026-12-31T22:30:00.000Z')), /^26-/)
})
test('tokenPasst: nur der passende Token, kein Hash als Token', () => {
const token = 'abc123'
const order = { tokenHash: B.tokenHash(token) }
assert.equal(B.tokenPasst(order, token), true)
assert.equal(B.tokenPasst(order, 'abc124'), false)
assert.equal(B.tokenPasst(order, order.tokenHash), false)
assert.equal(B.tokenPasst(order, ''), false)
assert.equal(B.tokenPasst(order, undefined), false)
assert.equal(B.tokenPasst({}, token), false)
assert.equal(B.tokenPasst({ tokenHash: '' }, ''), false)
})
test('pruefeRueckUrl: eigene Hosts und Portal ja, fremde, http, Zugangsdaten nein; wk_* und Fragment fallen weg', async () => {
const ctx = await aufbau()
const fallback = `${PORTAL}/shop/${SITE_KEY}`
const p = (u) => B.pruefeRueckUrl(u, ctx)
assert.equal(p('https://www.brot.example/laden?x=1&wk_t=alt&wk_abbruch=1#top'), 'https://www.brot.example/laden?x=1')
assert.equal(p('https://brot.example/'), fallback) // Apex nur, wenn das Projekt weitere Hosts hat (analyseDomains)
assert.equal(p(`${PORTAL}/shop/${SITE_KEY}/seite`), `${PORTAL}/shop/${SITE_KEY}/seite`)
for (const schlecht of ['https://boese.example/laden', 'http://www.brot.example/laden', 'https://www.brot.example.boese.example/', 'https://user:pw@www.brot.example/', 'https://www.brot.example:8443/', 'javascript:alert(1)', '//boese.example', '/relativ', '', undefined, null, 42, 'x'.repeat(3000)]) {
assert.equal(p(schlecht), fallback, String(schlecht).slice(0, 40))
}
// Ohne Projekt gilt nur das Portal
assert.equal(B.pruefeRueckUrl('https://www.brot.example/', { ...ctx, project: null }), fallback)
// Portal-Adresse http (Tests): http auf dem Portal-Host erlaubt, auf fremden Hosts nicht
config.publicBaseUrl = 'http://127.0.0.1:4000'
assert.equal(B.pruefeRueckUrl('http://127.0.0.1:4000/shop/x?wk_t=1', ctx), 'http://127.0.0.1:4000/shop/x')
assert.equal(B.pruefeRueckUrl('http://www.brot.example/', ctx), 'http://127.0.0.1:4000/shop/' + SITE_KEY)
})
test('M9: Wartezeit auf die Bestandssperre verkuerzt die Sitzungsdauer nicht - expires_at und ablaufAt werden vor dem Stripe-Aufruf nachgefuehrt', async () => {
const ctx = await aufbau()
let los
const gehalten = new Promise((r) => (los = r))
const sperre = withLocks([`shop-bestand:${FEATURE}`], () => gehalten)
const lauf = B.legeBestellungAn(ctx, eingabe(), { now: T0 })
await new Promise((r) => setTimeout(r, 2100))
los()
await sperre
await lauf
const [call] = sessionCalls()
const ende = Number(call.body.expires_at)
const grundwert = Math.ceil(T0.getTime() / 1000) + 30 * 60 + 30
assert.ok(ende >= grundwert + 2, `expires_at ${ende} soll mindestens 2 s nach ${grundwert} liegen`)
assert.ok(ende <= grundwert + 6)
assert.equal(new Date(orders()[0].ablaufAt).getTime(), ende * 1000, 'ablaufAt = Ende der Stripe-Sitzung')
})
test('Normalfall: Bestellung offen, Stripe-Kasse auf dem Haendlerkonto mit allen Parametern', async () => {
const ctx = await aufbau()
const r = await B.legeBestellungAn(ctx, eingabe(), { now: T0 })
assert.match(r.bestellnummer, /^26-[A-Z2-9]{6}$/)
assert.match(r.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/)
const [order] = orders()
assert.equal(order.$id, r.orderId)
assert.equal(order.status, 'offen')
assert.equal(order.bestellnummer, r.bestellnummer)
assert.equal(order.shopId, FEATURE)
assert.equal(order.customerId, KUNDE)
assert.equal(order.summeCent, 2 * 1250 + 490)
assert.equal(new Date(order.erstelltAt).toISOString(), T0.toISOString())
assert.equal(new Date(order.ablaufAt).getTime(), T0.getTime() + (30 * 60 + 30) * 1000)
assert.match(order.stripeSessionId, /^cs_/)
assert.match(order.tokenHash, /^[0-9a-f]{64}$/)
const daten = JSON.parse(order.bestellung)
assert.equal(daten.lieferart, 'versand')
assert.equal(daten.land, 'DE')
assert.equal(daten.positionen[0].menge, 2)
assert.deepEqual({ z: daten.summen.zwischensummeCent, v: daten.summen.versandCent, g: daten.summen.gesamtCent }, { z: 2500, v: 490, g: 2990 })
assert.equal(daten.anbieter.name, 'Brot & Mehr GmbH')
assert.equal(daten.shopname, 'Brot & Mehr')
assert.equal(daten.lieferzeit, EINSTELLUNGEN.lieferzeit)
assert.deepEqual(daten.zahlungsarten, ['card'])
assert.equal(daten.rechtstexteHash, HASH)
assert.equal(daten.abholung, undefined)
assert.equal(daten.verlauf.length, 1)
assert.equal(daten.verlauf[0].was, 'angelegt')
const [call] = sessionCalls()
assert.equal(sessionCalls().length, 1)
assert.equal(call.account, ACCOUNT)
assert.equal(call.idempotencyKey, `shop-order-${r.orderId}`)
const b = call.body
assert.equal(b.mode, 'payment')
assert.equal(b['line_items[0][quantity]'], '2')
assert.equal(b['line_items[0][price_data][currency]'], 'eur')
assert.equal(b['line_items[0][price_data][unit_amount]'], '1250')
assert.equal(b['line_items[0][price_data][product_data][name]'], 'Artikel a1')
assert.equal(b['line_items[0][price_data][product_data][images][0]'], undefined)
assert.equal(b['shipping_options[0][shipping_rate_data][type]'], 'fixed_amount')
assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '490')
assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][currency]'], 'eur')
assert.equal(b['shipping_options[0][shipping_rate_data][display_name]'], 'Versand nach Deutschland')
assert.equal(b['shipping_address_collection[allowed_countries][0]'], 'DE')
// M-6: Datenminimierung - keine Telefonnummer, Rechnungsanschrift nur bei Bedarf
assert.equal(b.billing_address_collection, 'auto')
assert.equal(b['phone_number_collection[enabled]'], 'false')
assert.equal(b['payment_method_types[0]'], 'card')
assert.equal(b['payment_method_types[1]'], undefined)
assert.equal(b.locale, 'de')
assert.equal(Number(b.expires_at), Math.ceil(T0.getTime() / 1000) + 30 * 60 + 30)
assert.equal(b.client_reference_id, r.orderId)
for (const prefix of ['metadata', 'payment_intent_data[metadata]']) {
assert.equal(b[`${prefix}[wkShop]`], '1')
assert.equal(b[`${prefix}[shopId]`], FEATURE)
assert.equal(b[`${prefix}[orderId]`], r.orderId)
assert.equal(b[`${prefix}[bestellnummer]`], r.bestellnummer)
}
assert.equal(b['payment_intent_data[description]'], `Bestellung ${r.bestellnummer} – Brot & Mehr`)
const success = new URL(b.success_url)
assert.equal(success.origin + success.pathname, 'https://www.brot.example/laden')
assert.equal(success.searchParams.get('x'), '1')
assert.equal(success.searchParams.get('wk_bestellung'), r.orderId)
assert.equal(success.hash, '')
const token = success.searchParams.get('wk_t')
assert.match(token, /^[A-Za-z0-9_-]{43}$/)
assert.equal(B.tokenPasst(order, token), true)
assert.equal(order.tokenHash.includes(token), false)
const cancel = new URL(b.cancel_url)
assert.equal(cancel.searchParams.get('wk_abbruch'), '1')
assert.equal(cancel.searchParams.get('x'), '1')
assert.equal(cancel.searchParams.get('wk_t'), null)
// bestellungLesen
const gelesen = await B.bestellungLesen(r.orderId)
assert.equal(gelesen.daten.shopname, 'Brot & Mehr')
assert.equal(gelesen.status, 'offen')
assert.equal(await B.bestellungLesen('gibt-es-nicht'), null)
assert.equal(await B.bestellungLesen('../x'), null)
assert.deepEqual(B.bestellDaten({ bestellung: '{kaputt' }), {})
})
test('Abholung, Variante, Bild und Landeswahl: Namen, Versandzeile und Bild-URL', async () => {
const ctx = await aufbau({
produkte: [
artikel('a1', { bilder: ['abc-123'], varianten: { name: 'Größe', werte: [{ name: 'M', preisCent: 2000 }, { name: 'L', preisCent: 2200 }] } }),
artikel('a2'),
],
})
const m = ctx && JSON.parse(aw.docs('shopProducts').get('a1').produkt).varianten.werte.find((w) => w.name === 'L')
const abholung = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', varianteId: m.id, menge: 1 }], lieferart: 'abholung', land: undefined }), { now: T0 })
const a = sessionCalls()[0].body
assert.equal(a['line_items[0][price_data][product_data][name]'], 'Artikel a1 – L')
assert.equal(a['line_items[0][price_data][unit_amount]'], '2200')
assert.equal(a['line_items[0][price_data][product_data][images][0]'], `${PORTAL}/api/public/shop/v1/${SITE_KEY}/bild/abc-123-l.webp`)
assert.equal(a['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '0')
assert.equal(a['shipping_options[0][shipping_rate_data][display_name]'], 'Abholung')
assert.equal(a['shipping_address_collection[allowed_countries][0]'], undefined)
const daten = JSON.parse(aw.docs('shopOrders').get(abholung.orderId).bestellung)
assert.deepEqual(daten.abholung, { adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' })
assert.equal(daten.land, null)
st.calls.length = 0
await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a2', menge: 1 }], land: 'AT' }), { now: T0 })
const v = sessionCalls()[0].body
assert.equal(v['shipping_options[0][shipping_rate_data][display_name]'], 'Versand nach Österreich')
assert.equal(v['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '790')
assert.equal(v['shipping_address_collection[allowed_countries][0]'], 'AT')
// Bild-URL nur mit https-Portal
st.calls.length = 0
config.publicBaseUrl = 'http://127.0.0.1:4000'
await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', varianteId: m.id, menge: 1 }], rueckUrl: undefined }), { now: T0 })
assert.equal(sessionCalls()[0].body['line_items[0][price_data][product_data][images][0]'], undefined)
assert.ok(sessionCalls()[0].body.success_url.startsWith(`http://127.0.0.1:4000/shop/${SITE_KEY}?wk_bestellung=`))
})
test('Preise nur aus der Datenbank: Preis, Summe und Versand vom Browser zaehlen nicht', async () => {
const ctx = await aufbau()
await B.legeBestellungAn(
ctx,
eingabe({ positionen: [{ produktId: 'a1', menge: 1, preisCent: 1, einzelCent: 1, summeCent: 1 }], versandCent: 0, gesamtCent: 1, summeCent: 1 }),
{ now: T0 }
)
const b = sessionCalls()[0].body
assert.equal(b['line_items[0][price_data][unit_amount]'], '1250')
assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '490')
assert.equal(orders()[0].summeCent, 1740)
})
test('Berechnungsfehler: unbekannter Artikel 409, falsche Menge 400, Lieferart 400; nichts angelegt, kein Stripe-Aufruf', async () => {
const ctx = await aufbau()
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'gibtsnicht', menge: 1 }] }), { now: T0 }), 409, 'nicht_verfuegbar')
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 0 }] }), { now: T0 }), 400, 'menge')
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ lieferart: 'post' }), { now: T0 }), 400, 'lieferart')
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [] }), { now: T0 }), 400, 'leer')
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ land: 'FR' }), { now: T0 }), 400, 'land')
await rejectsMit(B.legeBestellungAn(ctx, undefined, { now: T0 }), 400, 'leer')
const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'gibtsnicht', menge: 1 }] }), { now: T0 }).catch((e) => e)
assert.match(err.message, /nicht mehr verfügbar/)
assert.equal(orders().length, 0)
assert.equal(st.calls.length, 0)
})
test('Reservierung: der letzte Artikel geht nur einmal weg, abgelaufene Reservierungen geben ihn wieder frei', async () => {
const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] })
const erste = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 })
assert.deepEqual([...(await B.reserviert(FEATURE, nach(10))).entries()], [['a1', 1]])
const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: nach(60) }).catch((e) => e)
assert.equal(err.status, 409)
assert.equal(err.code, 'ausverkauft')
assert.match(err.message, /ausverkauft/)
assert.equal(orders().length, 1)
assert.equal(sessionCalls().length, 1)
// Nach Ablauf (30 min + 30 s) zaehlt die Reservierung nicht mehr
assert.equal((await B.reserviert(FEATURE, nach(31 * 60))).size, 0)
const zweite = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: nach(31 * 60) })
assert.notEqual(zweite.orderId, erste.orderId)
})
test('Begrenzung offener Bestellungen je IP-Hash: drei offen in 30 Minuten, danach 429; wieder frei, sobald eine nicht mehr offen ist', async () => {
const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 50 })] })
const mit = (ip, now) => B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now, ipHash: ip })
const ids = []
for (let i = 0; i < 3; i += 1) ids.push((await mit('aaaaaaaaaaaaaaaa', nach(i))).orderId)
assert.ok(orders().every((o) => JSON.parse(o.bestellung).ipHash === 'aaaaaaaaaaaaaaaa'), 'offen: IP-Hash steht im Datensatz')
const err = await mit('aaaaaaaaaaaaaaaa', nach(10)).catch((e) => e)
assert.equal(err.status, 429)
assert.equal(err.code, 'zu_viele_offen')
assert.ok((await mit('bbbbbbbbbbbbbbbb', nach(11))).orderId, 'andere IP unberuehrt')
// Eine verworfene Bestellung traegt keinen IP-Hash mehr und zaehlt nicht mehr
assert.equal((await B.verwirf(ids[0], 'zahlung_fehlgeschlagen', { now: nach(20) })).verworfen, true)
assert.equal(JSON.parse(orders().find((o) => o.$id === ids[0]).bestellung).ipHash, undefined)
assert.ok((await mit('aaaaaaaaaaaaaaaa', nach(21))).orderId, 'nach verwirf wieder eine offene moeglich')
const err2 = await mit('aaaaaaaaaaaaaaaa', nach(22)).catch((e) => e)
assert.equal(err2.code, 'zu_viele_offen')
})
test('reserviert: nur offene Bestellungen dieses Shops, Varianten mit Doppelpunkt-Schluessel, Mengen summiert', async () => {
await aufbau()
const ablauf = nach(1800).toISOString()
const pos = (produktId, menge, varianteId = null) => ({ produktId, varianteId, menge })
const bestellung = (...positionen) => JSON.stringify({ positionen })
aw.seed('shopOrders', { $id: 'o1', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 2), pos('p2', 1, 'v1')) })
aw.seed('shopOrders', { $id: 'o2', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 3), pos('p2', 4, 'v1'), pos('p2', 1, 'v2')) })
aw.seed('shopOrders', { $id: 'o3', shopId: FEATURE, status: 'bezahlt', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 9)) })
aw.seed('shopOrders', { $id: 'o4', shopId: 'anderer', status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 9)) })
aw.seed('shopOrders', { $id: 'o5', shopId: FEATURE, status: 'offen', ablaufAt: nach(-5).toISOString(), bestellung: bestellung(pos('p1', 9)) })
aw.seed('shopOrders', { $id: 'o6', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: '{kaputt' })
const karte = await B.reserviert(FEATURE, T0)
assert.deepEqual(Object.fromEntries(karte), { p1: 5, 'p2:v1': 5, 'p2:v2': 1 })
})
test('Gleichzeitig: zwei parallele Bestellungen fuer den letzten Artikel, genau eine gewinnt', async () => {
const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] })
const lauf = () => B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 })
const ergebnisse = await Promise.allSettled([lauf(), lauf(), lauf()])
assert.equal(ergebnisse.filter((x) => x.status === 'fulfilled').length, 1)
const abgelehnt = ergebnisse.filter((x) => x.status === 'rejected')
assert.equal(abgelehnt.length, 2)
for (const x of abgelehnt) assert.equal(x.reason.code, 'ausverkauft')
assert.equal(orders().length, 1)
assert.equal(sessionCalls().length, 1)
})
test('Geschlossener Shop: 409 geschlossen, nichts angelegt, kein Stripe-Aufruf', async () => {
for (const shop of [{ geoeffnet: false }, { stripeStatus: JSON.stringify({ charges_enabled: false }) }, { stripeAccountId: '' }]) {
const ctx = await aufbau({ shop })
const err = await B.legeBestellungAn(ctx, eingabe(), { now: T0 }).catch((e) => e)
assert.equal(err.status, 409, JSON.stringify(shop))
assert.equal(err.code, 'geschlossen')
assert.equal(err.message, 'Der Shop ist gerade geschlossen.')
assert.equal(orders().length, 0)
assert.equal(st.calls.length, 0)
}
// Funktion nicht aktiv
const ctx = await aufbau()
await rejectsMit(B.legeBestellungAn({ ...ctx, doc: { ...ctx.doc, state: 'disabled' } }, eingabe(), { now: T0 }), 409, 'geschlossen')
// Kein lieferbarer Artikel mehr
const leer = await aufbau({ produkte: [artikel('a1', { aktiv: false })] })
await rejectsMit(B.legeBestellungAn(leer, eingabe(), { now: T0 }), 409, 'geschlossen')
})
test('Fremde Rueck-URL: Fallback auf die Shop-Seite im Portal', async () => {
const ctx = await aufbau()
await B.legeBestellungAn(ctx, eingabe({ rueckUrl: 'https://boese.example/phishing?x=1' }), { now: T0 })
const b = sessionCalls()[0].body
const s = new URL(b.success_url)
assert.equal(s.origin + s.pathname, `${PORTAL}/shop/${SITE_KEY}`)
assert.equal(new URL(b.cancel_url).origin + new URL(b.cancel_url).pathname, `${PORTAL}/shop/${SITE_KEY}`)
assert.ok(!b.success_url.includes('boese'))
})
test('Stripe lehnt ab: Bestellung storniert (Reservierung frei), 503 mit Kaufertext', async () => {
const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] })
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'Invalid something' })
const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 }).catch((e) => e)
assert.equal(err.status, 503)
assert.equal(err.code, 'zahlung_nicht_moeglich')
assert.equal(err.message, 'Die Zahlung kann gerade nicht gestartet werden. Bitte versuchen Sie es später noch einmal.')
assert.equal(sessionCalls().length, 1)
const [order] = orders()
assert.equal(order.status, 'storniert')
const daten = JSON.parse(order.bestellung)
assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'stripe_fehler'])
assert.equal(order.stripeSessionId ?? '', '')
assert.equal((await B.reserviert(FEATURE, T0)).size, 0)
// Der Artikel ist wieder bestellbar
await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 })
})
test('Klarna nicht freigeschaltet: einmal mit Karte wiederholen, Hinweis fuer den Haendler speichern', async () => {
const ctx = await aufbau({ einstellungen: { ...EINSTELLUNGEN, zahlungsarten: ['card', 'klarna'] } })
assert.deepEqual(ctx.shop.einstellungen.zahlungsarten, ['card', 'klarna'])
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'The payment method type provided: klarna is invalid. Please ensure the provided type is activated in your dashboard (payment_method_types).' })
const r = await B.legeBestellungAn(ctx, eingabe(), { now: T0 })
const calls = sessionCalls()
assert.equal(calls.length, 2)
assert.equal(calls[0].body['payment_method_types[1]'], 'klarna')
assert.equal(calls[1].body['payment_method_types[0]'], 'card')
assert.equal(calls[1].body['payment_method_types[1]'], undefined)
assert.equal(calls[1].account, ACCOUNT)
assert.notEqual(calls[1].idempotencyKey, calls[0].idempotencyKey)
assert.equal(calls[1].body.client_reference_id, r.orderId)
assert.equal(calls[1].body.success_url, calls[0].body.success_url)
const [order] = orders()
assert.equal(order.status, 'offen')
assert.match(order.stripeSessionId, /^cs_/)
const status = JSON.parse(aw.docs('shopConfig').get(FEATURE).stripeStatus)
assert.equal(status.charges_enabled, true)
assert.match(status.hinweis, /^Klarna\/SEPA ist in deinem Stripe-Konto nicht aktiviert/)
// Zwischenspeicher geleert: der naechste Kontext enthaelt den Hinweis
assert.match((await K.shopPerSiteKey(SITE_KEY, { now: T0 })).shop.stripeStatus.hinweis, /Klarna/)
})
test('Fehler bei reiner Kartenzahlung wird nicht wiederholt', async () => {
const ctx = await aufbau()
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'bad payment_method_types' })
await rejectsMit(B.legeBestellungAn(ctx, eingabe(), { now: T0 }), 503, 'zahlung_nicht_moeglich')
assert.equal(sessionCalls().length, 1)
assert.equal(orders()[0].status, 'storniert')
})
test('Anderer Stripe-Fehler trotz Klarna-Auswahl: keine Wiederholung mit Karte', async () => {
const ctx = await aufbau({ einstellungen: { ...EINSTELLUNGEN, zahlungsarten: ['card', 'sepa_debit'] } })
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'No such account: acct_x' })
await rejectsMit(B.legeBestellungAn(ctx, eingabe(), { now: T0 }), 503, 'zahlung_nicht_moeglich')
assert.equal(sessionCalls().length, 1)
assert.equal(orders()[0].status, 'storniert')
assert.equal(JSON.parse(aw.docs('shopConfig').get(FEATURE).stripeStatus).hinweis, undefined)
})
+140
View File
@@ -0,0 +1,140 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync, existsSync } from 'node:fs'
import { readFile, writeFile } from 'node:fs/promises'
import sharp from 'sharp'
// Webshop C7: Artikelbilder (services/shop/bilder.js), verallgemeinert aus newsImages.js
process.env.SHOP_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'shop-bilder-'))
const {
speichereBild, bildPfad, bildMeta, beanspruche, loescheBild, loescheAlleBilder, raeumeUnbeanspruchteAuf, bildLimitErreicht,
setzeBildLimitZurueck, BILD_MAX_BYTES,
} = await import('../server/services/shop/bilder.js')
const KEY = 'abcdefghijklmnop'
const KEY2 = 'qrstuvwxyz234567'
const solid = (width, height) => sharp({ create: { width, height, channels: 3, background: '#36c' } })
const speichere = (buffer, extra = {}) => speichereBild({ customerId: 'c1', shopId: 's1', siteKey: KEY, buffer, ...extra })
test('EXIF/GPS entfernt und gedreht; Sidecar mit Besitzer, Shop und Groesse', async () => {
const input = await solid(1200, 800)
.withMetadata({ orientation: 6 })
.withExif({ IFD3: { GPSLatitudeRef: 'N', GPSLatitude: '48/1 30/1 0/1' } })
.jpeg().toBuffer()
assert.ok((await sharp(input).metadata()).exif, 'Fixture hat EXIF')
const { imageId, w, h } = await speichere(input)
assert.ok(w < h, 'gedreht')
for (const size of ['l', 's']) {
const meta = await sharp(bildPfad(KEY, imageId, size)).metadata()
assert.equal(meta.exif, undefined)
assert.equal(meta.format, 'webp')
}
const meta = await bildMeta(KEY, imageId)
assert.deepEqual([meta.customerId, meta.shopId, meta.claimed, meta.w, meta.h], ['c1', 's1', false, w, h])
assert.match(meta.createdAt, /^\d{4}-\d\d-\d\dT/)
})
test('verkleinert auf 1600 und 480, Dateinamen <id>-l.webp und <id>-s.webp unter <uploadDir>/<siteKey>/', async () => {
const { imageId, w, h } = await speichere(await solid(4000, 3000).png().toBuffer())
assert.deepEqual([w, h], [1600, 1200])
const l = await sharp(bildPfad(KEY, imageId, 'l')).metadata()
const s = await sharp(bildPfad(KEY, imageId, 's')).metadata()
assert.deepEqual([l.width, l.height, s.width, s.height], [1600, 1200, 480, 360])
assert.equal(bildPfad(KEY, imageId, 'l'), path.join(process.env.SHOP_UPLOAD_DIR, KEY, `${imageId}-l.webp`))
// kleine Bilder werden nicht vergroessert
const klein = await speichere(await solid(100, 50).webp().toBuffer())
assert.deepEqual([klein.w, klein.h], [100, 50])
})
test('zu gross 413, falsches Format 415 (PDF, GIF, kaputt, leer, kein Buffer), ungueltiger Shop 400', async () => {
await assert.rejects(speichere(Buffer.alloc(BILD_MAX_BYTES + 1)), { status: 413 })
assert.equal(BILD_MAX_BYTES, 8 * 1024 * 1024)
await assert.rejects(speichere(Buffer.from('%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n')), { status: 415 })
await assert.rejects(speichere(await solid(10, 10).gif().toBuffer()), { status: 415 })
await assert.rejects(speichere(Buffer.from('kein Bild')), { status: 415 })
await assert.rejects(speichere(Buffer.alloc(0)), { status: 415 })
await assert.rejects(speichere({}), { status: 415 })
await assert.rejects(speichere(await solid(10, 10).png().toBuffer(), { siteKey: '../x' }), { status: 400 })
})
test('Pfade nur fuer saubere siteKeys, IDs und Groessen', () => {
assert.equal(bildPfad(KEY, '../x', 'l'), null)
assert.equal(bildPfad(KEY, 'a/b', 'l'), null)
assert.equal(bildPfad(KEY, 'ABC', 'l'), null)
assert.equal(bildPfad('../abcdefghijkl', 'abc-1', 'l'), null)
assert.equal(bildPfad('abcdefghijklmno', 'abc-1', 'l'), null, 'zu kurz')
assert.equal(bildPfad('abcdefghijklmnop1', 'abc-1', 'l'), null, 'zu lang')
assert.equal(bildPfad('abcdefghijklmno1', 'abc-1', 'l'), null, 'Ziffer 1 ist kein base32')
assert.equal(bildPfad(KEY, 'abc-1', 'xl'), null)
assert.equal(bildPfad(KEY, 'x'.repeat(65), 'l'), null)
assert.ok(bildPfad(KEY, 'abc-1', 's').endsWith(path.join(KEY, 'abc-1-s.webp')))
})
test('bildMeta: unbekannte oder unsaubere IDs => null; beanspruche setzt claimed, ignoriert Unbekanntes und Wiederholung', async () => {
const { imageId } = await speichere(await solid(50, 50).png().toBuffer())
assert.equal(await bildMeta(KEY, 'gibtsnicht'), null)
assert.equal(await bildMeta(KEY, '../x'), null)
assert.equal(await bildMeta('zu-kurz', imageId), null)
await beanspruche(KEY, [imageId, 'gibtsnicht', '../x', 42])
assert.equal((await bildMeta(KEY, imageId)).claimed, true)
await beanspruche(KEY, [imageId])
assert.equal((await bildMeta(KEY, imageId)).claimed, true)
await beanspruche(KEY, undefined)
})
test('loescheBild entfernt beide Groessen und den Sidecar; loescheAlleBilder den ganzen Shop; Fehlendes ist kein Fehler', async () => {
const a = await speichere(await solid(50, 50).png().toBuffer())
const b = await speichere(await solid(50, 50).png().toBuffer())
const andere = await speichereBild({ customerId: 'c2', shopId: 's2', siteKey: KEY2, buffer: await solid(50, 50).png().toBuffer() })
await loescheBild(KEY, a.imageId)
await loescheBild(KEY, a.imageId)
await loescheBild(KEY, '../x')
for (const size of ['l', 's']) assert.equal(existsSync(bildPfad(KEY, a.imageId, size)), false)
assert.equal(await bildMeta(KEY, a.imageId), null)
assert.ok(await bildMeta(KEY, b.imageId))
await loescheAlleBilder(KEY)
await loescheAlleBilder('../x')
await loescheAlleBilder('gibtsnicht1234567')
assert.equal(await bildMeta(KEY, b.imageId), null)
assert.equal(existsSync(path.join(process.env.SHOP_UPLOAD_DIR, KEY)), false)
assert.ok(await bildMeta(KEY2, andere.imageId), 'anderer Shop bleibt')
})
test('raeumeUnbeanspruchteAuf: nur Unbeanspruchte aelter als 24 h, in allen Shops', async () => {
const png = await solid(50, 50).png().toBuffer()
const alt = await speichere(png)
const behalten = await speichere(png)
const frisch = await speichere(png)
const altAnderer = await speichereBild({ customerId: 'c2', shopId: 's2', siteKey: KEY2, buffer: png })
await beanspruche(KEY, [behalten.imageId])
const zurueckdatieren = async (key, id, stunden) => {
const datei = path.join(process.env.SHOP_UPLOAD_DIR, key, `${id}.json`)
const meta = JSON.parse(await readFile(datei, 'utf8'))
meta.createdAt = new Date(Date.now() - stunden * 3600 * 1000).toISOString()
await writeFile(datei, JSON.stringify(meta))
}
await zurueckdatieren(KEY, alt.imageId, 25)
await zurueckdatieren(KEY, behalten.imageId, 48)
await zurueckdatieren(KEY, frisch.imageId, 23)
await zurueckdatieren(KEY2, altAnderer.imageId, 30)
assert.equal(await raeumeUnbeanspruchteAuf(), 2)
assert.equal(await bildMeta(KEY, alt.imageId), null)
assert.equal(existsSync(bildPfad(KEY, alt.imageId, 'l')), false)
assert.equal(await bildMeta(KEY2, altAnderer.imageId), null)
assert.ok(await bildMeta(KEY, behalten.imageId), 'beansprucht bleibt')
assert.ok(await bildMeta(KEY, frisch.imageId), 'frisch bleibt')
// mit spaeterem Zeitpunkt wird auch das frische geraeumt
assert.equal(await raeumeUnbeanspruchteAuf(new Date(Date.now() + 3 * 3600 * 1000)), 1)
assert.equal(await bildMeta(KEY, frisch.imageId), null)
})
test('bildLimitErreicht: 40 je Stunde und Kunde, danach true; andere Kunden unberuehrt', () => {
setzeBildLimitZurueck()
for (let i = 0; i < 40; i++) assert.equal(bildLimitErreicht('c1'), false, `Aufruf ${i + 1}`)
assert.equal(bildLimitErreicht('c1'), true)
assert.equal(bildLimitErreicht('c2'), false)
setzeBildLimitZurueck()
assert.equal(bildLimitErreicht('c1'), false)
})
+254
View File
@@ -0,0 +1,254 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import Stripe from 'stripe'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Connect-Dienst des Webshops (Task D1) gegen den Stripe-Nachbau: gesendete Parameter, Kopfzeile Stripe-Account,
// Statusabbildung, Webhook-Signatur und Livemode-Regel.
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: st.url })
const { config } = await import('../server/config.js')
const connect = await import('../server/services/shop/connect.js')
const signer = new Stripe('sk_test_fake')
const SECRET = 'whsec_connect_test'
const KEY = config.stripe.secretKey
const customer = { $id: 'cust1', email: 'inhaber@beispiel.de' }
const project = { $id: 'proj1', projectName: 'Bäckerei Muster', liveDomain: 'https://www.baeckerei-muster.de/shop?x=1' }
after(() => {
config.stripe.secretKey = KEY
config.shop.connectWebhookSecret = ''
return st.close()
})
beforeEach(() => {
st.calls.length = 0
config.stripe.secretKey = KEY
config.shop.connectWebhookSecret = SECRET
})
const signiere = (event, secret = SECRET) => {
const payload = JSON.stringify(event)
return { payload, signatur: signer.webhooks.generateTestHeaderString({ payload, secret }) }
}
const rejects = (fn, status) => assert.rejects(Promise.resolve().then(fn), (err) => err.status === status)
test('kontoAnlegen sendet controller, Capabilities, Profil, Metadaten und Idempotenz-Schluessel', async () => {
const id = await connect.kontoAnlegen({ customer, project })
assert.match(id, /^acct_/)
const [call] = st.calls
assert.equal(call.method, 'POST')
assert.equal(call.path, '/v1/accounts')
assert.equal(call.account, undefined)
assert.equal(call.idempotencyKey, 'shop-konto-proj1')
assert.deepEqual(call.body, {
country: 'DE',
email: 'inhaber@beispiel.de',
'business_profile[name]': 'Bäckerei Muster',
'business_profile[url]': 'https://www.baeckerei-muster.de',
'controller[fees][payer]': 'account',
'controller[losses][payments]': 'stripe',
'controller[stripe_dashboard][type]': 'full',
'controller[requirement_collection]': 'stripe',
'capabilities[card_payments][requested]': 'true',
'capabilities[transfers][requested]': 'true',
'metadata[webklar]': 'shop',
'metadata[customerId]': 'cust1',
'metadata[projectId]': 'proj1',
})
const konto = st.accounts.get(id)
assert.equal(konto.controller.stripe_dashboard.type, 'full')
assert.equal(konto.charges_enabled, false)
})
test('kontoAnlegen: gleicher Projekt-Schluessel liefert dasselbe Konto, ohne Live-Domain keine business_profile.url', async () => {
const a = await connect.kontoAnlegen({ customer, project })
const b = await connect.kontoAnlegen({ customer, project })
assert.equal(a, b)
st.calls.length = 0
await connect.kontoAnlegen({ customer, project: { $id: 'proj2', projectName: 'Ohne Domain' } })
assert.equal(st.calls[0].body['business_profile[url]'], undefined)
assert.equal(st.calls[0].body['business_profile[name]'], 'Ohne Domain')
st.calls.length = 0
await connect.kontoAnlegen({ customer, project: { $id: 'proj3', projectName: 'Kaputt', liveDomain: 'nicht gültig' } })
assert.equal(st.calls[0].body['business_profile[url]'], undefined)
})
test('onboardingLink sendet Konto, Typ, Rueck-URLs und eventually_due', async () => {
const id = await connect.kontoAnlegen({ customer, project })
st.calls.length = 0
const url = await connect.onboardingLink(id, { returnUrl: 'https://p.test/zurueck', refreshUrl: 'https://p.test/neu' })
assert.match(url, /^https:\/\/connect\.stripe\.test\/setup\//)
assert.deepEqual(st.calls[0].body, {
account: id,
type: 'account_onboarding',
refresh_url: 'https://p.test/neu',
return_url: 'https://p.test/zurueck',
'collection_options[fields]': 'eventually_due',
})
})
test('kontoStatus bildet unfertige und fertige Konten ab', async () => {
const id = await connect.kontoAnlegen({ customer, project })
const neu = await connect.kontoStatus(id)
assert.deepEqual({ ...neu, geprueftAt: undefined }, {
charges_enabled: false,
payouts_enabled: false,
details_submitted: false,
currently_due: ['external_account'],
past_due: [],
disabled_reason: null,
geprueftAt: undefined,
})
assert.ok(!Number.isNaN(Date.parse(neu.geprueftAt)))
st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [] } })
const fertig = await connect.kontoStatus(id)
assert.equal(fertig.charges_enabled, true)
assert.equal(fertig.payouts_enabled, true)
assert.equal(fertig.details_submitted, true)
assert.deepEqual(fertig.currently_due, [])
st.setAccount(id, { charges_enabled: false, requirements: { past_due: ['individual.id_number'], disabled_reason: 'requirements.past_due' } })
const gesperrt = await connect.kontoStatus(id)
assert.deepEqual(gesperrt.past_due, ['individual.id_number'])
assert.equal(gesperrt.disabled_reason, 'requirements.past_due')
})
test('statusAusKonto ist rein und robust gegen fehlende Felder', () => {
const now = new Date('2026-10-05T10:00:00Z')
assert.deepEqual(connect.statusAusKonto({}, now), {
charges_enabled: false, payouts_enabled: false, details_submitted: false,
currently_due: [], past_due: [], disabled_reason: null, geprueftAt: '2026-10-05T10:00:00.000Z',
})
const eingabe = { charges_enabled: true, requirements: { currently_due: ['a'] } }
const aus = connect.statusAusKonto(eingabe, now)
aus.currently_due.push('b')
assert.deepEqual(eingabe.requirements.currently_due, ['a'])
})
test('statusAusKonto M6: lange Listen werden gekappt (30 Eintraege, 100 Zeichen), das Ergebnis passt als JSON in 4000 Zeichen', () => {
const lang = Array.from({ length: 80 }, (_, i) => `individual.verification.document.${'x'.repeat(200)}${i}`)
const aus = connect.statusAusKonto({ requirements: { currently_due: lang, past_due: lang, disabled_reason: 'r'.repeat(500) } })
assert.ok(aus.currently_due.length <= 30 && aus.past_due.length <= 30)
assert.ok([...aus.currently_due, ...aus.past_due].every((e) => e.length <= 100))
assert.ok(aus.disabled_reason.length <= 100)
assert.ok(JSON.stringify(aus).length <= 3500, `JSON ${JSON.stringify(aus).length} Zeichen`)
assert.ok(aus.currently_due.length > 0 && aus.past_due.length > 0, 'beide Listen behalten Eintraege')
// kurze Listen bleiben unveraendert, Nicht-Texte werden uebersprungen
assert.deepEqual(connect.statusAusKonto({ requirements: { currently_due: ['a', 5, null, 'b'] } }).currently_due, ['a', 'b'])
})
test('Checkout, Abruf, Ablauf: Stripe-Account-Kopfzeile, Idempotenz, Ablage je Konto', async () => {
const id = await connect.kontoAnlegen({ customer, project })
const other = 'acct_andere1'
st.calls.length = 0
const params = { mode: 'payment', success_url: 'https://x.test/ok', cancel_url: 'https://x.test/nein', metadata: { wkShop: '1', orderId: 'o1' }, expires_at: 1900000000 }
const sess = await connect.checkoutAnlegen(id, params, { idempotencyKey: 'shop-checkout-o1' })
assert.match(sess.id, /^cs_/)
assert.match(sess.url, /^https:\/\/checkout\.stripe\.test\//)
assert.equal(st.calls[0].account, id)
assert.equal(st.calls[0].idempotencyKey, 'shop-checkout-o1')
assert.equal(st.calls[0].body['metadata[wkShop]'], '1')
const again = await connect.checkoutAnlegen(id, params, { idempotencyKey: 'shop-checkout-o1' })
assert.equal(again.id, sess.id)
assert.equal(st.accountSessions(id).size, 1)
assert.equal(st.checkoutSessions.size, 0, 'Plattform-Ablage bleibt unberuehrt')
const geholt = await connect.sessionHolen(id, sess.id, { expand: ['payment_intent'] })
assert.equal(geholt.status, 'open')
const get = st.calls.at(-1)
assert.equal(get.account, id)
assert.equal(get.query['expand[0]'], 'payment_intent')
await assert.rejects(connect.sessionHolen(other, sess.id), (err) => err.statusCode === 404)
const expired = await connect.sessionAblaufen(id, sess.id)
assert.equal(expired.status, 'expired')
assert.equal(st.calls.at(-1).account, id)
assert.equal(st.calls.at(-1).path, `/v1/checkout/sessions/${sess.id}/expire`)
await assert.rejects(connect.sessionAblaufen(id, sess.id), (err) => err.statusCode === 400)
})
test('erstatten: Teil- und Restbetrag, Konto-Kopfzeile, Idempotenz, Obergrenze', async () => {
const id = await connect.kontoAnlegen({ customer, project })
st.setPaymentIntent(id, 'pi_abc', { amount: 2500 })
st.calls.length = 0
const teil = await connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 1000, grund: 'Artikel beschädigt', idempotencyKey: 'shop-erstattung-o1-1' })
assert.equal(teil.amount, 1000)
assert.deepEqual(st.calls[0].body, {
payment_intent: 'pi_abc',
amount: '1000',
reason: 'requested_by_customer',
'metadata[webklar]': 'shop',
'metadata[grund]': 'Artikel beschädigt',
})
assert.equal(st.calls[0].account, id)
assert.equal(st.calls[0].idempotencyKey, 'shop-erstattung-o1-1')
const wieder = await connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 1000, idempotencyKey: 'shop-erstattung-o1-1' })
assert.equal(wieder.id, teil.id)
assert.equal(st.refunds(id).size, 1)
const rest = await connect.erstatten(id, { paymentIntent: 'pi_abc' })
assert.equal(rest.amount, 1500, 'ohne Betrag der Rest')
await assert.rejects(connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 1 }), (err) => err.statusCode === 400)
await rejects(() => connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 10.5 }), 400)
await rejects(() => connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 0 }), 400)
await rejects(() => connect.erstatten(id, { paymentIntent: '../x', betragCent: 100 }), 400)
})
test('Konto-ID wird geprueft, bevor etwas gesendet wird', async () => {
st.calls.length = 0
for (const bad of ['', 'acct_', 'acct_a/b', 'cus_123', 'acct_ab cd', null, undefined, 'acct_x?y=1']) {
await rejects(() => connect.kontoStatus(bad), 400)
await rejects(() => connect.onboardingLink(bad, { returnUrl: 'https://a.test', refreshUrl: 'https://a.test' }), 400)
await rejects(() => connect.checkoutAnlegen(bad, {}), 400)
await rejects(() => connect.sessionHolen(bad, 'cs_test_1'), 400)
await rejects(() => connect.sessionAblaufen(bad, 'cs_test_1'), 400)
await rejects(() => connect.erstatten(bad, { paymentIntent: 'pi_1' }), 400)
}
await rejects(() => connect.sessionHolen('acct_ok1', '../x'), 400)
assert.equal(st.calls.length, 0)
})
test('Stripe-Fehler (Konto unbekannt) kommen als Fehler an', async () => {
await assert.rejects(connect.kontoStatus('acct_gibtesnicht'), (err) => err.statusCode === 404)
})
test('connectEreignis: gueltige Signatur liefert das Ereignis', () => {
const event = { id: 'evt_1', object: 'event', type: 'account.updated', account: 'acct_x1', livemode: false, data: { object: { id: 'acct_x1' } } }
const { payload, signatur } = signiere(event)
const aus = connect.connectEreignis(Buffer.from(payload), signatur)
assert.equal(aus.id, 'evt_1')
assert.equal(aus.account, 'acct_x1')
assert.equal(connect.connectEreignis(payload, signatur).id, 'evt_1', 'auch als Text')
})
test('connectEreignis: ungueltige oder fehlende Signatur ist 400', () => {
const event = { id: 'evt_1', object: 'event', type: 'account.updated', livemode: false, data: { object: {} } }
const { payload } = signiere(event)
assert.throws(() => connect.connectEreignis(payload, signiere(event, 'whsec_falsch').signatur), (err) => err.status === 400)
assert.throws(() => connect.connectEreignis(payload, undefined), (err) => err.status === 400)
assert.throws(() => connect.connectEreignis(payload, 'quatsch'), (err) => err.status === 400)
const { signatur } = signiere(event)
assert.throws(() => connect.connectEreignis(payload.replace('evt_1', 'evt_2'), signatur), (err) => err.status === 400)
})
test('connectEreignis: ohne Secret 503', () => {
config.shop.connectWebhookSecret = ''
const event = { id: 'evt_1', object: 'event', type: 'x', livemode: false, data: { object: {} } }
const { payload, signatur } = signiere(event)
assert.throws(() => connect.connectEreignis(payload, signatur), (err) => err.status === 503)
})
test('connectEreignis: mit Live-Schluessel zaehlen nur Live-Ereignisse', () => {
config.stripe.secretKey = 'sk_live_fake'
const test = { id: 'evt_t', object: 'event', type: 'account.updated', livemode: false, data: { object: {} } }
const live = { id: 'evt_l', object: 'event', type: 'account.updated', livemode: true, data: { object: {} } }
const a = signiere(test)
assert.equal(connect.connectEreignis(a.payload, a.signatur), null)
const b = signiere(live)
assert.equal(connect.connectEreignis(b.payload, b.signatur).id, 'evt_l')
// Signatur wird trotzdem zuerst geprueft
assert.throws(() => connect.connectEreignis(a.payload, b.signatur), (err) => err.status === 400)
// Testschluessel: beide Arten gehen durch
config.stripe.secretKey = KEY
assert.equal(connect.connectEreignis(a.payload, a.signatur).id, 'evt_t')
})
+276
View File
@@ -0,0 +1,276 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Einstellungen des Shops: reines Normalisieren (Fehler je Feld) und Speichern mit Versionspruefung gegen den Appwrite-Nachbau.
const aw = await startAppwriteMock({ schema: { shopConfig: schemaKeys('shopConfig') }, datetimes: SHOP_DATETIMES })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { createDocument, getDocument } = await import('../server/services/appwriteAdmin.js')
const E = await import('../server/services/shop/einstellungen.js')
const { STANDARD_EINSTELLUNGEN, normalisiereEinstellungen, ladeEinstellungen, speichereEinstellungen, nachSpeichern, istVollstaendig } = E
const komplett = (over = {}) => ({
shopname: 'Seifenmanufaktur Mustermann',
anbieter: {
name: 'Erika Mustermann',
strasse: 'Musterstraße 1',
plz: '80331',
ort: 'München',
land: 'DE',
email: 'Erika@Example.com',
telefon: '089 123456',
ustId: '',
},
kleinunternehmer: true,
versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null },
abholung: { aktiv: false, adresse: '', hinweis: '' },
lieferzeit: '2–4 Werktage',
zahlungsarten: ['card'],
akzent: '#111111',
mindestbestellCent: null,
ruecksendekosten: 'kaeufer',
...over,
})
const fehler = (eingabe) => normalisiereEinstellungen(eingabe).fehler
const ok = (eingabe) => {
const r = normalisiereEinstellungen(eingabe)
assert.equal(r.fehler, undefined, JSON.stringify(r.fehler))
return r.einstellungen
}
test('Standardwerte sind eingefroren, so wie im Plan und selbst gueltig', () => {
assert.ok(Object.isFrozen(STANDARD_EINSTELLUNGEN))
assert.ok(Object.isFrozen(STANDARD_EINSTELLUNGEN.versand))
assert.ok(Object.isFrozen(STANDARD_EINSTELLUNGEN.anbieter))
assert.deepEqual(STANDARD_EINSTELLUNGEN, {
shopname: '',
anbieter: { name: '', strasse: '', plz: '', ort: '', land: 'DE', email: '', telefon: '', ustId: '' },
kleinunternehmer: null,
versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null },
abholung: { aktiv: false, adresse: '', hinweis: '' },
lieferzeit: '',
zahlungsarten: ['card'],
akzent: '#111111',
mindestbestellCent: null,
ruecksendekosten: 'kaeufer',
})
assert.deepEqual(ok(STANDARD_EINSTELLUNGEN), STANDARD_EINSTELLUNGEN)
assert.equal(istVollstaendig(STANDARD_EINSTELLUNGEN), false)
})
test('komplette Eingabe: normalisiert, E-Mail klein, ohne gemeinsame Referenzen', () => {
const eingabe = komplett()
const e = ok(eingabe)
assert.equal(e.anbieter.email, 'erika@example.com')
assert.notEqual(e.versand, eingabe.versand)
assert.notEqual(e.versand.laender, eingabe.versand.laender)
assert.notEqual(e.versand.kostenCent, eingabe.versand.kostenCent)
assert.notEqual(e.anbieter, eingabe.anbieter)
assert.notEqual(e.zahlungsarten, eingabe.zahlungsarten)
e.versand.laender.push('X')
assert.deepEqual(eingabe.versand.laender, ['DE'])
assert.deepEqual(STANDARD_EINSTELLUNGEN.versand.laender, ['DE'])
assert.equal(istVollstaendig(komplett()), true)
})
test('leere und fehlende Eingabe: Standardwerte; keine Objekte: Fehler', () => {
assert.deepEqual(ok({}), STANDARD_EINSTELLUNGEN)
for (const x of [null, undefined, 'x', 5, [], true]) assert.ok(fehler(x)?.einstellungen, String(x))
})
test('unbekannte Schluessel werden ignoriert, auch __proto__', () => {
const e = ok(JSON.parse('{"foo":1,"__proto__":{"x":1},"anbieter":{"zusatz":"a"},"versand":{"y":2}}'))
assert.deepEqual(Object.keys(e).sort(), Object.keys(STANDARD_EINSTELLUNGEN).sort())
assert.equal(e.anbieter.zusatz, undefined)
assert.equal(e.versand.y, undefined)
assert.equal({}.x, undefined)
})
test('Texte: getrimmt, Steuerzeichen entfernt, Laengengrenzen je Feld', () => {
const e = ok(komplett({ shopname: ' Laden\u0000​ Eins \n', abholung: { aktiv: true, adresse: 'Weg 1\r\n80331 München\u0007', hinweis: 'Klingeln' } }))
assert.equal(e.shopname, 'Laden Eins')
assert.equal(e.abholung.adresse, 'Weg 1\n80331 München')
const grenzen = [
['shopname', komplett({ shopname: 'a'.repeat(81) })],
['anbieter.name', komplett({ anbieter: { ...komplett().anbieter, name: 'a'.repeat(121) } })],
['anbieter.strasse', komplett({ anbieter: { ...komplett().anbieter, strasse: 'a'.repeat(121) } })],
['anbieter.ort', komplett({ anbieter: { ...komplett().anbieter, ort: 'a'.repeat(81) } })],
['anbieter.telefon', komplett({ anbieter: { ...komplett().anbieter, telefon: '1'.repeat(41) } })],
['lieferzeit', komplett({ lieferzeit: 'a'.repeat(81) })],
['abholung.hinweis', komplett({ abholung: { aktiv: true, adresse: 'Weg 1', hinweis: 'a'.repeat(301) } })],
['abholung.adresse', komplett({ abholung: { aktiv: true, adresse: 'a'.repeat(301), hinweis: '' } })],
]
for (const [pfad, eingabe] of grenzen) assert.ok(fehler(eingabe)?.[pfad], pfad)
ok(komplett({ shopname: 'a'.repeat(80), lieferzeit: 'abc' }))
assert.ok(fehler(komplett({ lieferzeit: 'ab' }))?.lieferzeit)
assert.ok(fehler(komplett({ shopname: 5 }))?.shopname)
})
test('Anbieter: E-Mail, PLZ je Land, Land, USt-IdNr.', () => {
const an = (over) => komplett({ anbieter: { ...komplett().anbieter, ...over } })
for (const mail of ['x', 'a@b', 'a b@c.de', 'a@b.de, c@d.de', `${'a'.repeat(250)}@b.de`, 'a@@b.de', '<a@b.de>']) {
assert.ok(fehler(an({ email: mail }))?.['anbieter.email'], mail)
}
assert.equal(ok(an({ email: ' A@B.de ' })).anbieter.email, 'a@b.de')
assert.ok(fehler(an({ plz: '8033' }))?.['anbieter.plz'])
assert.ok(fehler(an({ plz: '80a31' }))?.['anbieter.plz'])
assert.equal(ok(an({ land: 'AT', plz: '1010' })).anbieter.land, 'AT')
assert.ok(fehler(an({ land: 'AT', plz: '80331' }))?.['anbieter.plz'])
assert.ok(fehler(an({ land: 'FR' }))?.['anbieter.land'])
assert.equal(ok(an({ ustId: ' de123456789 ' })).anbieter.ustId, 'DE123456789')
assert.equal(ok(an({ ustId: 'ATU12345678' })).anbieter.ustId, 'ATU12345678')
for (const u of ['DE12345678', 'FR123456789', 'ATU1234567', 'DE1234567890']) assert.ok(fehler(an({ ustId: u }))?.['anbieter.ustId'], u)
})
test('Versand: Laender, Kosten je Land, Freigrenze', () => {
const vs = (over) => komplett({ versand: { ...komplett().versand, ...over } })
const at = ok(vs({ laender: ['AT', 'DE'], kostenCent: { DE: 490, AT: 990, FR: 5 } }))
assert.deepEqual(at.versand.laender, ['DE', 'AT'])
assert.deepEqual(at.versand.kostenCent, { DE: 490, AT: 990 })
assert.ok(fehler(vs({ laender: ['DE', 'AT'], kostenCent: { DE: 490 } }))?.['versand.kostenCent.AT'])
assert.ok(fehler(vs({ kostenCent: { DE: 4.9 } }))?.['versand.kostenCent.DE'])
assert.ok(fehler(vs({ kostenCent: { DE: '490' } }))?.['versand.kostenCent.DE'])
assert.ok(fehler(vs({ kostenCent: { DE: -1 } }))?.['versand.kostenCent.DE'])
assert.ok(fehler(vs({ kostenCent: { DE: 100001 } }))?.['versand.kostenCent.DE'])
assert.equal(ok(vs({ kostenCent: { DE: 0 } })).versand.kostenCent.DE, 0)
assert.equal(ok(vs({ kostenCent: { DE: 100000 } })).versand.kostenCent.DE, 100000)
for (const l of [['AT'], ['DE', 'FR'], [], 'DE', ['DE', 'DE', 'AT', 'CH']]) assert.ok(fehler(vs({ laender: l }))?.['versand.laender'], JSON.stringify(l))
assert.equal(ok(vs({ freiAbCent: 5000 })).versand.freiAbCent, 5000)
assert.equal(ok(vs({ freiAbCent: null })).versand.freiAbCent, null)
for (const f of [0, -5, 12.5, '5000', Infinity]) assert.ok(fehler(vs({ freiAbCent: f }))?.['versand.freiAbCent'], String(f))
assert.ok(fehler(vs({ aktiv: 'ja' }))?.['versand.aktiv'])
})
test('mindestens Versand oder Abholung; Abholadresse Pflicht', () => {
const nichts = fehler(komplett({ versand: { ...komplett().versand, aktiv: false } }))
assert.ok(nichts?.['versand.aktiv'])
const abholungNur = ok(komplett({ versand: { ...komplett().versand, aktiv: false }, abholung: { aktiv: true, adresse: 'Lager 3', hinweis: 'Mo–Fr' } }))
assert.equal(abholungNur.abholung.adresse, 'Lager 3')
assert.ok(fehler(komplett({ abholung: { aktiv: true, adresse: ' ', hinweis: '' } }))?.['abholung.adresse'])
// inaktive Abholung braucht keine Adresse
ok(komplett({ abholung: { aktiv: false, adresse: '', hinweis: '' } }))
})
test('Zahlungsarten: card immer dabei, nur bekannte, feste Reihenfolge', () => {
assert.deepEqual(ok(komplett({ zahlungsarten: ['sepa_debit', 'klarna'] })).zahlungsarten, ['card', 'klarna', 'sepa_debit'])
assert.deepEqual(ok(komplett({ zahlungsarten: ['card', 'card'] })).zahlungsarten, ['card'])
assert.deepEqual(ok(komplett({ zahlungsarten: [] })).zahlungsarten, ['card'])
assert.ok(fehler(komplett({ zahlungsarten: ['card', 'paypal'] }))?.zahlungsarten)
assert.ok(fehler(komplett({ zahlungsarten: 'card' }))?.zahlungsarten)
})
test('Akzent, Mindestbestellwert, Ruecksendekosten, Kleinunternehmer', () => {
assert.equal(ok(komplett({ akzent: '#AABBCC' })).akzent, '#aabbcc')
for (const a of ['AABBCC', '#abc', '#12345g', 'red', '#1234567']) assert.ok(fehler(komplett({ akzent: a }))?.akzent, a)
assert.equal(ok(komplett({ mindestbestellCent: 1000 })).mindestbestellCent, 1000)
for (const m of [0, -1, 1.5, '10']) assert.ok(fehler(komplett({ mindestbestellCent: m }))?.mindestbestellCent, String(m))
assert.equal(ok(komplett({ ruecksendekosten: 'haendler' })).ruecksendekosten, 'haendler')
assert.ok(fehler(komplett({ ruecksendekosten: 'egal' }))?.ruecksendekosten)
assert.equal(ok(komplett({ kleinunternehmer: false })).kleinunternehmer, false)
assert.ok(fehler(komplett({ kleinunternehmer: 'nein' }))?.kleinunternehmer)
// I-4: keine Vorbelegung - null (oder fehlend) bleibt null, zaehlt aber nicht als vollstaendig
assert.equal(ok(komplett({ kleinunternehmer: null })).kleinunternehmer, null)
assert.equal(ok({ ...komplett(), kleinunternehmer: undefined }).kleinunternehmer, null)
assert.equal(istVollstaendig(komplett({ kleinunternehmer: null })), false)
assert.equal(istVollstaendig(komplett({ kleinunternehmer: true })), true)
assert.equal(istVollstaendig(komplett({ kleinunternehmer: false })), true)
assert.equal(istVollstaendig(komplett({ lieferzeit: undefined })), false, 'Lieferzeit ohne Vorbelegung')
})
test('mehrere Fehler zugleich, alle Texte in Du-Form ohne Sie', () => {
const f = fehler(komplett({ akzent: 'x', lieferzeit: 'a', zahlungsarten: ['paypal'], anbieter: { ...komplett().anbieter, email: 'x', plz: '1' } }))
assert.deepEqual(Object.keys(f).sort(), ['akzent', 'anbieter.email', 'anbieter.plz', 'lieferzeit', 'zahlungsarten'])
for (const t of Object.values(f)) {
assert.equal(typeof t, 'string')
assert.doesNotMatch(t, /\bSie\b|\bIhre?[mnrs]?\b/)
}
})
test('istVollstaendig: Pflichtangaben und gueltige Normalisierung', () => {
assert.equal(istVollstaendig(komplett()), true)
for (const pfad of ['shopname', 'lieferzeit']) assert.equal(istVollstaendig(komplett({ [pfad]: '' })), false, pfad)
for (const feld of ['name', 'strasse', 'plz', 'ort', 'email']) {
assert.equal(istVollstaendig(komplett({ anbieter: { ...komplett().anbieter, [feld]: '' } })), false, feld)
}
assert.equal(istVollstaendig(komplett({ anbieter: { ...komplett().anbieter, telefon: '', ustId: '' } })), true)
assert.equal(istVollstaendig(komplett({ akzent: 'kaputt' })), false)
assert.equal(istVollstaendig(null), false)
})
test('ladeEinstellungen: kaputt oder fehlend ergibt Standard, Teilwerte werden aufgefuellt', () => {
for (const doc of [null, undefined, {}, { einstellungen: '' }, { einstellungen: '{kaputt' }, { einstellungen: '[1]' }, { einstellungen: 'null' }, { einstellungen: '"x"' }]) {
assert.deepEqual(ladeEinstellungen(doc), STANDARD_EINSTELLUNGEN)
}
const teil = ladeEinstellungen({ einstellungen: JSON.stringify({ shopname: 'Laden', anbieter: { ort: 'Ulm' }, versand: { freiAbCent: 5000 } }) })
assert.equal(teil.shopname, 'Laden')
assert.equal(teil.anbieter.ort, 'Ulm')
assert.equal(teil.anbieter.land, 'DE')
assert.equal(teil.versand.freiAbCent, 5000)
assert.deepEqual(teil.versand.kostenCent, { DE: 490 })
assert.equal(teil.lieferzeit, '')
assert.equal(teil.kleinunternehmer, null, 'Steuerart ist nicht vorbelegt')
// gespeicherte Werte werden nicht in die Standardwerte geschrieben
teil.versand.laender.push('AT')
assert.deepEqual(STANDARD_EINSTELLUNGEN.versand.laender, ['DE'])
assert.notEqual(ladeEinstellungen(null), STANDARD_EINSTELLUNGEN)
})
test('speichereEinstellungen: Version steigt, Versionspruefung, Hooks', async () => {
const id = 'f_p_shop0001_shop'
await createDocument(config.collections.shopConfig, { customerId: 'c1', projectId: 'p1', siteKey: 'abcdefghijklmnop' }, id)
const gerufen = []
const abmelden = nachSpeichern((shopId) => gerufen.push(shopId))
nachSpeichern(() => {
throw new Error('Hook kaputt')
})
nachSpeichern(async () => {
throw new Error('Hook async kaputt')
})
const r1 = await speichereEinstellungen(id, komplett(), { erwarteteVersion: 0 })
assert.equal(r1.version, 1)
assert.equal(r1.einstellungen.anbieter.email, 'erika@example.com')
let doc = await getDocument(config.collections.shopConfig, id)
assert.equal(doc.version, 1)
assert.equal(JSON.parse(doc.einstellungen).shopname, 'Seifenmanufaktur Mustermann')
assert.deepEqual(ladeEinstellungen(doc), r1.einstellungen)
assert.deepEqual(gerufen, [id])
const r2 = await speichereEinstellungen(id, komplett({ shopname: 'Neu' }))
assert.equal(r2.version, 2)
await assert.rejects(speichereEinstellungen(id, komplett({ shopname: 'Alt' }), { erwarteteVersion: 1 }), (err) => {
assert.equal(err.status, 409)
assert.equal(err.message, 'Die Einstellungen wurden inzwischen geändert. Bitte lade die Seite neu.')
return true
})
doc = await getDocument(config.collections.shopConfig, id)
assert.equal(doc.version, 2)
assert.equal(JSON.parse(doc.einstellungen).shopname, 'Neu')
assert.equal(gerufen.length, 2)
// ungueltige Einstellungen werden nicht gespeichert: 400 mit Feldfehlern
await assert.rejects(speichereEinstellungen(id, komplett({ akzent: 'x' })), (err) => err.status === 400 && Boolean(err.fehler.akzent))
assert.equal((await getDocument(config.collections.shopConfig, id)).version, 2)
assert.equal(gerufen.length, 2)
// gleichzeitige Speicherungen mit derselben erwarteten Version: genau eine gewinnt
const rennen = await Promise.allSettled([
speichereEinstellungen(id, komplett({ shopname: 'A' }), { erwarteteVersion: 2 }),
speichereEinstellungen(id, komplett({ shopname: 'B' }), { erwarteteVersion: 2 }),
])
assert.equal(rennen.filter((x) => x.status === 'fulfilled').length, 1)
assert.equal((await getDocument(config.collections.shopConfig, id)).version, 3)
abmelden()
await speichereEinstellungen(id, komplett({ shopname: 'C' }))
assert.equal(gerufen.length, 3) // A oder B war der dritte Aufruf, danach keiner mehr
// unbekannter Shop
await assert.rejects(speichereEinstellungen('gibt-es-nicht', komplett()), (err) => err.status === 404)
})
+238
View File
@@ -0,0 +1,238 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, statSync } from 'node:fs'
import vm from 'node:vm'
// Statische Pruefung des Einbau-Skripts public/embed/shop.js (Plan 2026-10-05-webshop.md, Task H1; Muster test/termineEmbedStatic.test.js):
// - Kundentext nur ueber textContent/Attribute: kein innerHTML, kein eval.
// - Das Widget rechnet nie: keine Datums-, Zahlen- oder Zeitzonenformatierung im Browser (alles kommt als fertiger Text vom Server).
// - Einziger Browser-Speicher ist der Warenkorb in der sessionStorage, immer in try/catch; keine Cookies, keine Fremdadressen im Code.
// - Pflichttexte der Kasse und der Widerrufsfunktion (§ 312j Abs. 3 BGB, § 356a BGB) stehen im Skript.
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
const QUELLE = lies('public/embed/shop.js')
/** Quelltext ohne Kommentare (die Kopfzeile darf Beispiele mit Adressen enthalten, der Code nicht). */
const OHNE_KOMMENTARE = QUELLE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
test('Quelltext ist gueltiges JavaScript (kompiliert, wird nicht ausgefuehrt) und unter 120 KB', () => {
assert.doesNotThrow(() => new vm.Script(QUELLE, { filename: 'shop.js' }))
assert.ok(statSync(new URL('../public/embed/shop.js', import.meta.url)).size < 120 * 1024, 'Skript ist zu gross')
})
test('ES5-Stil wie termine.js: IIFE mit use strict, keine Pfeilfunktionen, let/const, Schablonenzeichenketten', () => {
assert.match(OHNE_KOMMENTARE, /^\s*\(function \(\) \{\s*'use strict'/)
assert.doesNotMatch(OHNE_KOMMENTARE, /=>/, 'keine Pfeilfunktionen')
assert.doesNotMatch(OHNE_KOMMENTARE, /\b(?:let|const)\s/, 'var statt let/const')
assert.ok(!OHNE_KOMMENTARE.includes('`'), 'keine Schablonenzeichenketten')
})
test('innerHTML gar nicht (Fremddaten nur ueber textContent); kein outerHTML, insertAdjacentHTML, document.write, eval, new Function', () => {
assert.ok(!/innerHTML/.test(OHNE_KOMMENTARE), 'innerHTML darf im Widget nicht vorkommen')
for (const verboten of ['outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'setTimeout("', "setTimeout('", 'createContextualFragment', 'srcdoc']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`)
}
})
test('keine Zahlen-, Datums- oder Zeitzonenrechnung im Browser (Date, Intl, toLocale, toFixed)', () => {
assert.ok(!/\bDate\b/.test(OHNE_KOMMENTARE), 'Date darf nicht vorkommen')
for (const verboten of ['toLocale', 'Intl.', 'toFixed', 'getTimezoneOffset', 'resolvedOptions', 'getFullYear', 'getHours', 'Date.now']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`)
}
})
test('keine Cookies, kein localStorage, kein Referrer, kein Beacon; keine festen Fremdadressen im Code', () => {
for (const verboten of ['localStorage', 'document.cookie', 'indexedDB', 'document.referrer', 'sendBeacon', 'navigator.userAgent']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`)
}
assert.doesNotMatch(OHNE_KOMMENTARE, /https?:\/\//, 'Adressen gehoeren in data-api, nicht in den Code')
assert.doesNotMatch(OHNE_KOMMENTARE, /\bimport\s*\(|\bimportScripts\b|\bXMLHttpRequest\b|\bWebSocket\b/)
})
test('sessionStorage nur fuer den Warenkorb unter wks-korb-<siteKey> und jeder Zugriff steht in try/catch', () => {
const treffer = [...OHNE_KOMMENTARE.matchAll(/sessionStorage/g)]
assert.ok(treffer.length >= 3, 'lesen, schreiben, entfernen')
for (const t of treffer) {
const davor = OHNE_KOMMENTARE.slice(0, t.index)
const tryPos = davor.lastIndexOf('try {')
assert.ok(tryPos >= 0, 'sessionStorage ohne try davor')
const zwischen = davor.slice(tryPos)
assert.ok(!/\bcatch\b/.test(zwischen), 'sessionStorage steht ausserhalb des try-Blocks')
assert.ok(zwischen.length < 600, 'try und sessionStorage liegen weit auseinander')
}
assert.ok(OHNE_KOMMENTARE.includes("'wks-korb-'"), 'Schluessel wks-korb-<siteKey>')
// gespeichert werden nur IDs und Mengen: das Array [id, variante, menge]
assert.match(OHNE_KOMMENTARE, /korb\.map\(function \(z\) \{ return \[z\.id, z\.v, z\.n\] \}\)/)
})
test('Anfragen ohne Cookies und nur an die Shop-Schnittstelle, eine Stelle fuer alle Anfragen, Zeitgrenze 20 s', () => {
assert.ok(OHNE_KOMMENTARE.includes("credentials: 'omit'"), "fetch braucht credentials: 'omit'")
assert.ok(OHNE_KOMMENTARE.includes('/api/public/shop/v1/'))
assert.equal([...OHNE_KOMMENTARE.matchAll(/\bfetch\(/g)].length, 1, 'genau ein fetch')
assert.match(OHNE_KOMMENTARE, /TIMEOUT_MS\s*=\s*20000\b/)
for (const pfad of ["'/pruefen'", "'/bestellung'", "'/widerruf'", "'/bestellung/'"]) assert.ok(OHNE_KOMMENTARE.includes(pfad), `Pfad ${pfad} fehlt`)
assert.equal([...OHNE_KOMMENTARE.matchAll(/request\('POST', '\/bestellung'/g)].length, 1, 'genau eine Stelle legt Bestellungen an')
})
test('Widerruf: Bestellnummer freiwillig mit Hinweis auf Bestelldatum und Artikel (§ 356a BGB)', () => {
assert.ok(QUELLE.includes("label: 'Bestellnummer (falls zur Hand)'"))
assert.ok(QUELLE.includes('Ohne Bestellnummer: Nennen Sie bitte Bestelldatum und Artikel im Feld „Erklärung“, damit wir Ihren Vertrag zuordnen können.'))
assert.ok(QUELLE.includes('Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.'), 'Pruefung im Formular ohne Nummer')
assert.ok(!/label: 'Bestellnummer'/.test(QUELLE))
})
test('Pflichttexte: Knopf der Kasse, Widerrufsfunktion, Gewaehrleistungsbild, Stripe-Hinweis, Fuss, Kopfzeile, geschlossen', () => {
for (const noetig of [
"'Zahlungspflichtig bestellen'", "'Vertrag widerrufen'", "'Widerruf bestätigen'", "'Weiter'",
'Name, Lieferadresse und Zahlung geben Sie im nächsten Schritt bei unserem Zahlungsdienstleister Stripe ein.',
'Der Shop ist gerade geschlossen.', 'Shop-Technik: WEBklar', 'Lieferung nach: ', 'Abholung möglich', 'Zahlung: ',
"'AGB'", "'Widerrufsbelehrung'", "'Versand & Zahlung'", "'Impressum'", "'Datenschutz'", 'zzgl. Versand',
'Vielen Dank für Ihre Bestellung', 'bei uns eingegangen. Eine Eingangsbestätigung haben wir Ihnen per E-Mail geschickt.',
'rel = \'noopener noreferrer\'', 'img.alt = g.text', "img.loading = 'eager'", 'gewaehrleistung',
'wk_bestellung', 'wk_t', 'wk_abbruch', 'replaceState', 'kontakt_url', 'formToken', 'rueckUrl: location.href', 'nurWiderruf',
'data-webklar-shop', 'data-api', 'data-ansicht', "'ansicht'", 'aria-live', 'aria-pressed', 'tabIndex = -1', 'showModal',
]) {
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`)
}
// der Knopf heisst genau so (kein Zusatz) und steht in der Kasse
assert.ok(OHNE_KOMMENTARE.includes("el('button', 'wks-btn', 'Zahlungspflichtig bestellen')"))
assert.ok(OHNE_KOMMENTARE.includes("el('button', 'wks-btn', 'Widerruf bestätigen')"))
assert.ok(OHNE_KOMMENTARE.includes("knopf('wks-btn wks-btn-sek', 'Vertrag widerrufen'"), 'Widerruf im Fuss ist ein Knopf')
})
test('I-2: Gewährleistungsgrafik steht hervorgehoben vor der Zusammenfassung, höchstens 420 px, sofort geladen, mit Vergrößern-Dialog', () => {
assert.ok(OHNE_KOMMENTARE.includes("'Ihre gesetzlichen Gewährleistungsrechte'"), 'Überschrift')
assert.match(OHNE_KOMMENTARE, /\.wks-gewaehr img\{[^']*width:100%;max-width:420px/)
assert.match(OHNE_KOMMENTARE, /\.wks-gewaehr\{[^']*border:2px solid/)
const kastenCode = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function gewaehrKasten'), OHNE_KOMMENTARE.indexOf('function honigfeld'))
assert.ok(kastenCode.includes("img.alt = g.text") && !kastenCode.includes('lazy'), 'Pflichtbild nicht verzögert laden, Alt-Text = voller Text')
assert.ok(OHNE_KOMMENTARE.includes("img.loading = 'eager'"))
assert.ok(OHNE_KOMMENTARE.includes("'Vergrößern'"), 'Knopf')
assert.match(OHNE_KOMMENTARE, /dialogOeffnen\('Ihre gesetzlichen Gewährleistungsrechte', \[\{ bild: bildUrl, text: g\.text \}\]/)
assert.match(OHNE_KOMMENTARE, /e\.key === 'Escape'/, 'Esc schließt')
assert.ok(OHNE_KOMMENTARE.includes('knopf(\'wks-schliessen\', \'×\', dialogSchliessen)'), 'Schließen-Knopf')
// Reihenfolge in der Kasse: Kasten -> Zusammenfassung -> AGB-Hinweis -> Knopf
const i = (t) => OHNE_KOMMENTARE.indexOf(t)
const kasten = i('form.appendChild(gkasten)')
const pflicht = i('form.appendChild(pflicht)')
const hinweis = i('form.appendChild(agbHinweis)')
const knopf = i("var senden = el('button', 'wks-btn', 'Zahlungspflichtig bestellen')")
assert.ok(kasten > 0 && kasten < pflicht && pflicht < hinweis && hinweis < knopf, 'Reihenfolge')
// nicht mehr in der Zusammenfassung (die wird bei jeder Änderung neu aufgebaut)
const fuelle = OHNE_KOMMENTARE.slice(i('function fuellePflicht'), i('function landName'))
assert.ok(!fuelle.includes('gewaehr'), 'Gewährleistung nicht in fuellePflicht')
})
test('I-3: ausdrücklicher AGB-Hinweis direkt über dem Bestellknopf, AGB und Widerrufsbelehrung öffnen die Texte', () => {
const stelle = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf("var agbHinweis = el('p'"), OHNE_KOMMENTARE.indexOf("var senden = el('button'"))
assert.ok(stelle.includes("createTextNode('Es gelten unsere ')"))
assert.ok(stelle.includes("knopf('wks-link', 'AGB', function (e) { zeigeAgb(e.currentTarget) })"))
assert.ok(stelle.includes("createTextNode('. Informationen zu Ihrem Widerrufsrecht finden Sie in der ')"))
assert.ok(stelle.includes("knopf('wks-link', 'Widerrufsbelehrung', function (e) { zeigeBelehrung(e.currentTarget) })"))
assert.ok(stelle.includes("createTextNode('.')"))
})
test('M-3: "zzgl. Versand" steht bei jedem Preis (Karte und Detail) und nur, wenn der Shop versendet - Abholshops zeigen es nicht', () => {
assert.equal([...OHNE_KOMMENTARE.matchAll(/'zzgl\. Versand'/g)].length, 1, 'der Text steht an genau einer Stelle (versandLink)')
const helfer = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function versandAktiv'), OHNE_KOMMENTARE.indexOf('function gewaehrKasten'))
assert.match(helfer, /function versandAktiv \(\) \{ return liste\(shop\(\)\.lieferlaender\)\.length > 0 \}/, 'Versand = es gibt Lieferländer')
assert.match(helfer, /return versandAktiv\(\) \? knopf\('wks-link', 'zzgl\. Versand'/)
const karte = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function karte (p)'), OHNE_KOMMENTARE.indexOf('// ------------------------------------------------------------ Detail'))
assert.ok(karte.indexOf("el('p', 'wks-preis'") < karte.indexOf('versandLink()'), 'Hinweis nach dem Preis in der Karte')
const raster = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function zeichneRaster'), OHNE_KOMMENTARE.indexOf('function karte (p)'))
assert.ok(!raster.includes('Versand'), 'unter dem Raster steht nur der Steuerhinweis')
const detail = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function zeichneDetail'), OHNE_KOMMENTARE.indexOf('function aktualisierePreis'))
assert.ok(detail.includes('versandLink()') && detail.includes('if (hinweis.childNodes.length) rechts.appendChild(hinweis)'))
})
test('Weiterleitung zu Stripe nur zu einer https-Adresse (httpsUrl), Links der Rechtstexte nur als https', () => {
assert.match(OHNE_KOMMENTARE, /var ziel = d \? httpsUrl\(d\.url\) : ''/)
assert.match(OHNE_KOMMENTARE, /location\.href = ziel/)
assert.equal([...OHNE_KOMMENTARE.matchAll(/location\.href\s*=/g)].length, 1, 'nur eine Weiterleitung')
assert.match(OHNE_KOMMENTARE, /httpsUrl\(r\.impressumUrl\)/)
assert.match(OHNE_KOMMENTARE, /httpsUrl\(r\.datenschutzUrl\)/)
assert.match(OHNE_KOMMENTARE, /httpsUrl\(g\.bild\)/)
// Bildpfade des Servers (ohne Host) bekommen nur die API-Adresse vorangestellt
assert.match(OHNE_KOMMENTARE, /function absUrl \(api, pfad\)/)
})
test('Die Zusammenfassung der Kasse kommt vom Server (/pruefen) und wird bei jeder Aenderung neu geholt; veraltete Antworten verworfen', () => {
assert.match(OHNE_KOMMENTARE, /request\('POST', '\/pruefen'/)
assert.ok(OHNE_KOMMENTARE.includes('pruefSeq'), 'Zaehler gegen veraltete Antworten')
assert.match(OHNE_KOMMENTARE, /senden\.disabled = true/, 'Knopf bleibt gesperrt, bis die Zusammenfassung da ist')
// Preise werden nie im Widget gerechnet: keine Multiplikation von Preisen
assert.doesNotMatch(OHNE_KOMMENTARE, /preisCent|summeCent|einzelCent|parseFloat/)
})
test('Warten auf das Formular-Token (3 s) vor Bestellung und Widerruf; Konfiguration nach 100 min erneuert', () => {
assert.match(OHNE_KOMMENTARE, /FORM_MIN_VORGABE_S\s*=\s*3\b/)
assert.match(OHNE_KOMMENTARE, /FORM_PUFFER_MS\s*=\s*500\b/)
assert.match(OHNE_KOMMENTARE, /CONFIG_ALTER_MAX_MS\s*=\s*100 \* 60 \* 1000/)
for (const stelle of ["request('POST', '/bestellung'", "request('POST', '/widerruf'"]) {
const vor = OHNE_KOMMENTARE.slice(0, OHNE_KOMMENTARE.indexOf(stelle))
const warte = vor.lastIndexOf('warteAufFormToken(senden)')
assert.ok(warte >= 0 && vor.length - warte < 400, `${stelle} nur nach warteAufFormToken`)
}
})
test('Warenkorb-Rueckkehr: bis zu fuenf Abrufe im Abstand von 2 s solange die Bestellung offen ist', () => {
assert.match(OHNE_KOMMENTARE, /STATUS_VERSUCHE\s*=\s*5\b/)
assert.match(OHNE_KOMMENTARE, /STATUS_ABSTAND_MS\s*=\s*2000\b/)
assert.ok(OHNE_KOMMENTARE.includes("d.status === 'offen' && st.versuch < STATUS_VERSUCHE"))
})
test('Stile stehen einmal in <style id="wks-style"> im @layer wks, alle Klassen beginnen mit wks-', () => {
const css = /var CSS = \[([\s\S]*?)\]\.join/.exec(QUELLE)
assert.ok(css, 'CSS-Block fehlt')
assert.ok(css[1].includes('@layer wks{'))
const klassen = [...css[1].matchAll(/\.([a-zA-Z][\w-]*)/g)].map((m) => m[1]).filter((k) => !/^\d/.test(k))
assert.ok(klassen.length > 40)
for (const k of klassen) assert.ok(k.startsWith('wks-'), `Klasse ${k} ohne Praefix wks-`)
assert.equal([...QUELLE.matchAll(/getElementById\(STYLE_ID\)/g)].length, 1)
assert.ok(QUELLE.includes("'wks-style'"))
for (const variable of ['--wks-akzent', '--wks-akzent-text', '--wks-hintergrund', '--wks-text', '--wks-rand', '--wks-radius', '--wks-schrift', '--wks-schrift-titel']) {
assert.ok(QUELLE.includes(variable), `${variable} fehlt`)
}
})
test('Ausweichtext wird nie verbraucht (nur Kopien zurueck auf die Seite)', () => {
assert.ok(!/appendChild\(ausweich\)/.test(OHNE_KOMMENTARE), 'der Ausweichtext darf nur als cloneNode(true) zurueckgesetzt werden')
assert.ok(OHNE_KOMMENTARE.includes('ausweich.cloneNode(true)'))
})
/** Die reinen Farbfunktionen des Skripts (zwischen den Marken @farben-anfang und @farben-ende) einzeln ausfuehren. */
function farbFunktionen() {
const m = /\/\/ @farben-anfang[^\n]*\n([\s\S]*?)\/\/ @farben-ende/.exec(QUELLE)
assert.ok(m, 'Marken @farben-anfang/@farben-ende fehlen')
return vm.runInNewContext(`${m[1]}; ({ luminanz, kontrast, textFarbeAuf })`, {})
}
function wcagKontrast(hexA, hexB) {
const l = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
const [x, y] = [l(hexA), l(hexB)]
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
}
test('Textfarbe auf der Akzentfarbe erreicht mindestens 4,5 : 1 (Grautoene und ein Raster ueber den Farbraum)', () => {
const { textFarbeAuf } = farbFunktionen()
const hex = (r, g, b) => `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`
const proben = ['#aa3300', '#ea580c', '#16a34a', '#0ea5e9', '#14b8a6', '#ec4899', '#fde68a', '#ffffff', '#000000']
for (let g = 0; g < 256; g += 5) proben.push(hex(g, g, g))
for (let r = 0; r < 256; r += 51) for (let g = 0; g < 256; g += 51) for (let b = 0; b < 256; b += 51) proben.push(hex(r, g, b))
for (const farbe of proben) {
const text = textFarbeAuf(farbe)
assert.ok(['#ffffff', '#111827', '#000000'].includes(text), `${farbe}: unerwartete Textfarbe ${text}`)
assert.ok(wcagKontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${wcagKontrast(farbe, text).toFixed(2)} : 1`)
}
})
test('Demo-Seite: noindex, Platzhalter-siteKey, Ausweichtext, Skript per defer', () => {
const demo = lies('public/embed/shop-demo.html')
assert.match(demo, /<meta name="robots" content="noindex"/)
assert.match(demo, /<script src="\/embed\/shop\.js" defer><\/script>/)
assert.match(demo, /data-webklar-shop="abcdefghijklmnop"/)
assert.match(demo, /Bestellungen telefonisch/, 'Ausweichtext fehlt')
})
+79
View File
@@ -0,0 +1,79 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { euro, parseEuro, grundpreisText } from '../server/services/shop/geld.js'
const NB = ' '
test('euro formatiert ganze Cent deutsch mit geschuetztem Leerzeichen', () => {
assert.equal(euro(123456), `1.234,56${NB}€`)
assert.equal(euro(0), `0,00${NB}€`)
assert.equal(euro(5), `0,05${NB}€`)
assert.equal(euro(1250), `12,50${NB}€`)
assert.equal(euro(100), `1,00${NB}€`)
assert.equal(euro(99999999), `999.999,99${NB}€`)
assert.equal(euro(100000000), `1.000.000,00${NB}€`)
assert.equal(euro(-1250), `-12,50${NB}€`)
assert.equal(euro(-5), `-0,05${NB}€`)
})
test('euro lehnt Nicht-Ganzzahlen ab', () => {
for (const v of [12.5, NaN, Infinity, '1250', null, undefined, {}]) {
assert.throws(() => euro(v), TypeError)
}
})
test('parseEuro: gueltige Eingaben', () => {
const faelle = [
[12.5, 1250], [12, 1200], [0, 0], [19.99, 1999], [0.1, 10], [1.005, null],
['12', 1200], ['12,5', 1250], ['12,50', 1250], ['12,50 €', 1250], [`12,50${NB}€`, 1250],
[' 12,50 ', 1250], ['€ 12,50', 1250], ['1.234,56', 123456], ['1.234.567,00', null],
['1234,56', 123456], ['1234.56', 123456], ['12.5', 1250], ['1.23', 123], ['1.234', 123400],
['12.500', 1250000], ['0,99', 99], ['0', 0], ['007,5', 750], ['1000', 100000],
['100000', 10000000], ['100.000,00', 10000000], ['12.345,6', 1234560], ['99999,99', 9999999],
]
for (const [eingabe, cent] of faelle) assert.equal(parseEuro(eingabe), cent, `parseEuro(${JSON.stringify(eingabe)})`)
})
test('parseEuro: ungueltige Eingaben ergeben null', () => {
const faelle = [
'', ' ', '€', 'abc', '12,5x', '-5', '-0,50', -5, NaN, Infinity, null, undefined, {}, [],
'12,345', '1.2345', '1234.567', '12,', ',50', '1,2,3', '1..234,00', '1.23.456',
'100000,01', 100000.01, '100001', 1e21, 0.30000000000000004, '12 50', '1 234,56', '1e3',
]
for (const eingabe of faelle) assert.equal(parseEuro(eingabe), null, `parseEuro(${JSON.stringify(eingabe)})`)
})
test('grundpreisText: Bezugsmengen und Format', () => {
assert.equal(grundpreisText({ preisCent: 1250, menge: 750, einheit: 'g' }), `(16,67${NB}€ / 1 kg)`)
assert.equal(grundpreisText({ preisCent: 1250, menge: 1, einheit: 'kg' }), `(12,50${NB}€ / 1 kg)`)
assert.equal(grundpreisText({ preisCent: 299, menge: 500, einheit: 'ml' }), `(5,98${NB}€ / 1 l)`)
assert.equal(grundpreisText({ preisCent: 299, menge: 0.75, einheit: 'l' }), `(3,99${NB}€ / 1 l)`)
assert.equal(grundpreisText({ preisCent: 500, menge: 2, einheit: 'm' }), `(2,50${NB}€ / 1 m)`)
assert.equal(grundpreisText({ preisCent: 1000, menge: 4, einheit: 'm2' }), `(2,50${NB}€ / 1 m²)`)
})
test('grundpreisText: kaufmaennische Rundung ohne Gleitkommafehler', () => {
// 1 / 8 = 0,125 -> 0,13 (halb auf)
assert.equal(grundpreisText({ preisCent: 1, menge: 8, einheit: 'kg' }), `(0,00${NB}€ / 1 kg)`)
assert.equal(grundpreisText({ preisCent: 5, menge: 8, einheit: 'kg' }), `(0,01${NB}€ / 1 kg)`)
assert.equal(grundpreisText({ preisCent: 1005, menge: 2, einheit: 'kg' }), `(5,03${NB}€ / 1 kg)`)
assert.equal(grundpreisText({ preisCent: 1001, menge: 2, einheit: 'l' }), `(5,01${NB}€ / 1 l)`)
assert.equal(grundpreisText({ preisCent: 333, menge: 3, einheit: 'm' }), `(1,11${NB}€ / 1 m)`)
assert.equal(grundpreisText({ preisCent: 1, menge: 1000, einheit: 'g' }), `(0,01${NB}€ / 1 kg)`)
assert.equal(grundpreisText({ preisCent: 10, menge: 0.1, einheit: 'kg' }), `(1,00${NB}€ / 1 kg)`)
assert.equal(grundpreisText({ preisCent: 3, menge: 0.3, einheit: 'l' }), `(0,10${NB}€ / 1 l)`)
})
test('grundpreisText: fehlende oder ungueltige Angaben ergeben leeren Text', () => {
const ok = { preisCent: 1250, menge: 750, einheit: 'g' }
assert.equal(grundpreisText(), '')
assert.equal(grundpreisText(null), '')
assert.equal(grundpreisText({}), '')
for (const aend of [
{ menge: 0 }, { menge: -1 }, { menge: NaN }, { menge: Infinity }, { menge: '' }, { menge: null }, { menge: undefined }, { menge: 'abc' },
{ einheit: 'stk' }, { einheit: 'KG' }, { einheit: '' }, { einheit: undefined },
{ preisCent: 12.5 }, { preisCent: -1 }, { preisCent: undefined }, { preisCent: '1250' },
]) {
assert.equal(grundpreisText({ ...ok, ...aend }), '', JSON.stringify(aend))
}
})
+453
View File
@@ -0,0 +1,453 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { startAppwriteMock } from './support/appwriteMock.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Webshop J3: Tagesjob (Anonymisieren, Unbezahltes loeschen, abgeschaltete Shops loeschen), Stundenjob, Kundenloeschung, Registrierung.
// Alles gegen den Appwrite-Nachbau und einen temporaeren Upload-Ordner; die Uhr ist immer ein fester Zeitpunkt.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
},
datetimes: SHOP_DATETIMES,
defaultLimit: 25,
})
const uploadDir = mkdtempSync(path.join(os.tmpdir(), 'shop-jobs-'))
process.env.SHOP_UPLOAD_DIR = uploadDir
after(() => {
rmSync(uploadDir, { recursive: true, force: true })
return aw.close()
})
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument, getDocument } = await import('../server/services/appwriteAdmin.js')
const { taeglicherJob, stuendlicherJob, minutenJob, anonymisierGrenze } = await import('../server/services/shop/jobs.js')
const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js')
const sf = await import('../server/services/siteFeatures.js')
const KEY = 'abcdefghijklmnop'
const KEY2 = 'bcdefghijklmnopq'
const TAG = 24 * 3600 * 1000
const JETZT = new Date('2026-10-05T10:00:00.000Z')
const vor = (tage, von = JETZT) => new Date(von.getTime() - tage * TAG).toISOString()
let n = 0
const id = (prefix) => `${prefix}${++n}`
const shop = (shopId, over = {}) => aw.seed('shopConfig', { $id: shopId, customerId: 'cs1', projectId: 'p1', siteKey: KEY, geoeffnet: true, ...over })
const feature = (shopId, over = {}) =>
aw.seed('portalFeatures', { $id: shopId, customerId: 'cs1', projectId: 'p1', featureKey: 'shop', siteKey: KEY, state: 'active', enabled: true, ...over })
const PERSON = {
kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '0123' }, // alter Datensatz: Telefonnummer aus der Zeit vor der Datenminimierung
lieferadresse: { line1: 'Hauptstr. 1', postal_code: '67722', city: 'Winnweiler', country: 'DE' },
rechnungsadresse: { line1: 'Hauptstr. 1', postal_code: '67722', city: 'Winnweiler', country: 'DE' },
}
const bestellung = (shopId, over = {}, daten = {}) => {
const doc = {
$id: id('o'),
shopId,
customerId: 'cs1',
bestellnummer: '26-ABC123',
status: 'versendet',
summeCent: 2500,
erstelltAt: vor(10),
bezahltAt: vor(10),
kaeuferEmail: 'max@example.com',
bestellung: JSON.stringify({ positionen: [{ produktId: 'a', name: 'Tasse', menge: 2, einzelCent: 1000 }], summen: { gesamtCent: 2500 }, ...PERSON, ...daten }),
...over,
}
aw.seed('shopOrders', doc)
return doc.$id
}
const widerruf = (shopId, over = {}) => {
const doc = { $id: id('w'), shopId, bestellnummer: '26-ABC123', name: 'Max Mustermann', email: 'max@example.com', nachricht: 'Ich widerrufe.', eingangAt: vor(10), ...over }
aw.seed('shopWiderrufe', doc)
return doc.$id
}
const produkt = (shopId) => {
const doc = { $id: id('pr'), shopId, customerId: 'cs1', aktiv: true, sort: 1, name: 'Tasse', produkt: '{}' }
aw.seed('shopProducts', doc)
return doc.$id
}
const bild = (siteKey, bildId = id('img')) => {
const ordner = path.join(uploadDir, siteKey)
mkdirSync(ordner, { recursive: true })
writeFileSync(path.join(ordner, `${bildId}-l.webp`), 'x')
writeFileSync(path.join(ordner, `${bildId}-s.webp`), 'x')
writeFileSync(path.join(ordner, `${bildId}.json`), JSON.stringify({ claimed: true, createdAt: vor(400) }))
return bildId
}
const order = (orderId) => aw.docs('shopOrders').get(orderId)
const daten = (orderId) => JSON.parse(order(orderId).bestellung)
beforeEach(async () => {
for (const name of ['shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'portalFeatures']) {
for (const docId of aw.docs(name).keys()) await deleteDocument(config.collections[name], docId)
}
rmSync(uploadDir, { recursive: true, force: true })
mkdirSync(uploadDir, { recursive: true })
config.shop.mode = 'on'
config.shop.loeschDeckel = undefined
})
// ------------------------------------------------------------------------------------------------ Anonymisieren
test('anonymisierGrenze: 1. Januar Berlin, drei Jahre nach Ende des Bestelljahres', () => {
// Bestelljahr 2026 -> Ende 31.12.2026 -> +3 Jahre = 01.01.2030 (Berlin)
assert.equal(anonymisierGrenze(new Date('2029-12-31T22:59:59.999Z')).toISOString(), '2025-12-31T23:00:00.000Z', 'noch 2029 in Berlin: Grenze = 01.01.2026 (Bestelljahr 2026 ist noch nicht dran)')
assert.equal(anonymisierGrenze(new Date('2029-12-31T23:00:00.000Z')).toISOString(), '2026-12-31T23:00:00.000Z', 'ab 01.01.2030 00:00 Berlin: Grenze = 01.01.2027 (Bestelljahr 2026 ist dran)')
})
test('Bestellung aus 2026 wird genau am 1. Januar 2030 (Berlin) anonymisiert, nicht eine Sekunde vorher', async () => {
shop('s_a')
const o = bestellung('s_a', { erstelltAt: '2026-06-01T10:00:00.000Z' }, { ipHash: 'abcdef0123456789' })
const vorher = await taeglicherJob(new Date('2029-12-31T22:59:59.000Z')) // 23:59:59 Berlin
assert.equal(vorher.bestellungenAnonymisiert, 0)
assert.equal(order(o).anonymisiertAt, undefined)
assert.equal(order(o).kaeuferEmail, 'max@example.com')
const nachher = await taeglicherJob(new Date('2029-12-31T23:00:00.000Z')) // 00:00 Berlin, 1.1.2030
assert.equal(nachher.bestellungenAnonymisiert, 1)
assert.equal(nachher.fehler, 0)
const doc = order(o)
assert.equal(doc.kaeuferEmail, '')
assert.ok(doc.anonymisiertAt)
const d = daten(o)
assert.equal(d.ipHash, undefined, 'der IP-Hash wird mit anonymisiert')
assert.deepEqual(d.kaeufer, { name: '', email: '' }, 'auch eine alte Telefonnummer ist weg')
assert.equal('lieferadresse' in d, false)
assert.equal('rechnungsadresse' in d, false)
assert.equal(d.positionen[0].name, 'Tasse', 'Positionen bleiben')
assert.equal(d.summen.gesamtCent, 2500, 'Betraege bleiben')
assert.equal(doc.bestellnummer, '26-ABC123')
assert.equal(doc.status, 'versendet')
assert.equal(doc.summeCent, 2500)
assert.ok(!JSON.stringify(doc).includes('Mustermann'))
})
test('Silvester-Nacht: eine Bestellung vom 31.12.2026 23:30 UTC gehoert in Berlin zu 2027 und folgt erst 2031', async () => {
shop('s_b')
const o = bestellung('s_b', { erstelltAt: '2026-12-31T23:30:00.000Z' })
await taeglicherJob(new Date('2030-06-01T10:00:00.000Z'))
assert.equal(order(o).anonymisiertAt, undefined, '2027 ist 2030 noch nicht dran')
await taeglicherJob(new Date('2031-01-01T10:00:00.000Z'))
assert.ok(order(o).anonymisiertAt)
})
test('Anonymisierung ist idempotent und fasst andere Shops und junge Bestellungen nicht an', async () => {
shop('s_c')
shop('s_d', { siteKey: KEY2 })
const alt = bestellung('s_c', { erstelltAt: '2020-03-01T10:00:00.000Z' })
const jung = bestellung('s_c', { erstelltAt: '2026-03-01T10:00:00.000Z' })
const erste = await taeglicherJob(JETZT)
assert.equal(erste.bestellungenAnonymisiert, 1)
const stand = order(alt).anonymisiertAt
const zweite = await taeglicherJob(new Date(JETZT.getTime() + TAG))
assert.equal(zweite.bestellungenAnonymisiert, 0)
assert.equal(order(alt).anonymisiertAt, stand)
assert.equal(order(jung).kaeuferEmail, 'max@example.com')
assert.equal(daten(jung).kaeufer.name, 'Max Mustermann')
})
test('Widerrufe: Name, E-Mail und Nachricht werden nach derselben Frist (eingangAt) geleert, die Bestellnummer bleibt', async () => {
shop('s_e')
const alt = widerruf('s_e', { eingangAt: '2026-02-01T10:00:00.000Z' })
const jung = widerruf('s_e', { eingangAt: '2027-02-01T10:00:00.000Z' })
assert.equal((await taeglicherJob(new Date('2029-12-31T22:59:59.000Z'))).widerrufeAnonymisiert, 0)
const erg = await taeglicherJob(new Date('2030-01-01T10:00:00.000Z'))
assert.equal(erg.widerrufeAnonymisiert, 1)
const a = aw.docs('shopWiderrufe').get(alt)
assert.deepEqual([a.name, a.email, a.nachricht, a.bestellnummer], ['', '', '', '26-ABC123'])
assert.equal(aw.docs('shopWiderrufe').get(jung).email, 'max@example.com')
assert.equal((await taeglicherJob(new Date('2030-01-02T10:00:00.000Z'))).widerrufeAnonymisiert, 0, 'idempotent')
})
// ------------------------------------------------------------------------------------------------ Unbezahltes
test('unbezahlte Bestellungen: storniert und alte offene nach 30 Tagen weg; bezahlte, junge und solche mit bezahltAt bleiben', async () => {
shop('s_f')
const storniertAlt = bestellung('s_f', { status: 'storniert', erstelltAt: vor(31), bezahltAt: null })
const storniertJung = bestellung('s_f', { status: 'storniert', erstelltAt: vor(29), bezahltAt: null })
const offenAlt = bestellung('s_f', { status: 'offen', erstelltAt: vor(40), bezahltAt: null })
const offenJung = bestellung('s_f', { status: 'offen', erstelltAt: vor(2), bezahltAt: null })
const bezahltAlt = bestellung('s_f', { status: 'versendet', erstelltAt: vor(100) })
const stornoMitZahlung = bestellung('s_f', { status: 'storniert', erstelltAt: vor(100), bezahltAt: vor(100) })
const erg = await taeglicherJob(JETZT)
assert.equal(erg.unbezahltGeloescht, 2)
assert.equal(erg.fehler, 0)
for (const weg of [storniertAlt, offenAlt]) assert.equal(aw.docs('shopOrders').has(weg), false)
for (const da of [storniertJung, offenJung, bezahltAlt, stornoMitZahlung]) assert.equal(aw.docs('shopOrders').has(da), true)
assert.equal((await taeglicherJob(JETZT)).unbezahltGeloescht, 0)
})
// ------------------------------------------------------------------------------------------------ Abgeschaltete Shops
test('Shop seit 91 Tagen disabled: Artikel, Bestellungen, Widerrufe, Bilder und shopConfig weg; Funktionsdokument bleibt disabled mit Vermerk', async () => {
shop('s_g')
feature('s_g', { state: 'disabled', enabled: false, disabledAt: vor(91), disabledReason: 'team', adminNote: 'war aus' })
const p = produkt('s_g')
const o = bestellung('s_g')
const w = widerruf('s_g')
const b = bild(KEY)
// anderer Shop: aktiv, und einer erst 30 Tage disabled: bleiben unberuehrt
shop('s_h', { siteKey: KEY2 })
feature('s_h', { siteKey: KEY2, projectId: 'p2' })
const o2 = bestellung('s_h')
const bild2 = bild(KEY2)
shop('s_i', { siteKey: 'cdefghijklmnopqr' })
feature('s_i', { siteKey: 'cdefghijklmnopqr', projectId: 'p3', state: 'disabled', disabledAt: vor(30) })
const o3 = bestellung('s_i')
const erg = await taeglicherJob(JETZT)
assert.equal(erg.shopsGeloescht, 1)
assert.equal(erg.shopDokumenteGeloescht, 3)
assert.equal(erg.fehler, 0)
assert.equal(aw.docs('shopProducts').has(p), false)
assert.equal(aw.docs('shopOrders').has(o), false)
assert.equal(aw.docs('shopWiderrufe').has(w), false)
assert.equal(aw.docs('shopConfig').has('s_g'), false)
assert.equal(existsSync(path.join(uploadDir, KEY)), false)
assert.equal(existsSync(path.join(uploadDir, KEY, `${b}-l.webp`)), false)
const f = aw.docs('portalFeatures').get('s_g')
assert.equal(f.state, 'disabled')
assert.equal(f.adminNote, 'Shopdaten gelöscht am 2026-10-05')
assert.ok(f.adminNote.length <= 300)
assert.equal(aw.docs('shopOrders').has(o2), true)
assert.equal(aw.docs('shopConfig').has('s_h'), true)
assert.equal(existsSync(path.join(uploadDir, KEY2, `${bild2}-l.webp`)), true)
assert.equal(aw.docs('shopOrders').has(o3), true, '30 Tage reichen nicht')
assert.equal(aw.docs('shopConfig').has('s_i'), true)
// zweiter Lauf: nichts mehr zu tun, der Vermerk haelt ihn kurz
aw.resetListCalls()
const nochmal = await taeglicherJob(new Date(JETZT.getTime() + TAG))
assert.equal(nochmal.shopsGeloescht, 0)
assert.equal(aw.listCalls('shopProducts').length, 0)
})
test('Shop ohne siteKey-Bilder und ohne Dokumente: shopConfig wird trotzdem entfernt; wieder eingeschaltete Shops bleiben', async () => {
shop('s_j')
feature('s_j', { state: 'disabled', disabledAt: vor(100) })
shop('s_k', { siteKey: KEY2 })
feature('s_k', { siteKey: KEY2, state: 'active', disabledAt: null })
const o = bestellung('s_k')
const erg = await taeglicherJob(JETZT)
assert.equal(erg.shopsGeloescht, 1)
assert.equal(aw.docs('shopConfig').has('s_j'), false)
assert.equal(aw.docs('shopOrders').has(o), true)
})
test('Deckel: je Lauf hoechstens config.shop.loeschDeckel Loeschungen, der Rest folgt an den naechsten Tagen', async () => {
config.shop.loeschDeckel = 3
shop('s_l')
feature('s_l', { state: 'disabled', disabledAt: vor(120) })
const ids = { o: [], w: [], p: [] }
for (let i = 0; i < 4; i++) ids.o.push(bestellung('s_l'))
for (let i = 0; i < 2; i++) ids.w.push(widerruf('s_l'))
ids.p.push(produkt('s_l'))
bild(KEY)
const gesamt = () => aw.docs('shopOrders').size + aw.docs('shopWiderrufe').size + aw.docs('shopProducts').size
assert.equal(gesamt(), 7)
const tag1 = await taeglicherJob(JETZT)
assert.equal(tag1.gedeckelt, true)
assert.equal(tag1.shopsGeloescht, 0)
assert.equal(gesamt(), 4)
assert.equal(aw.docs('shopConfig').has('s_l'), true, 'shopConfig bleibt, bis alles weg ist')
assert.equal(existsSync(path.join(uploadDir, KEY)), true, 'Bilder auch')
const tag2 = await taeglicherJob(new Date(JETZT.getTime() + TAG))
assert.equal(gesamt(), 1)
assert.equal(tag2.shopsGeloescht, 0)
const tag3 = await taeglicherJob(new Date(JETZT.getTime() + 2 * TAG))
assert.equal(gesamt(), 0)
assert.equal(tag3.shopsGeloescht, 1)
assert.equal(aw.docs('shopConfig').has('s_l'), false)
assert.equal(existsSync(path.join(uploadDir, KEY)), false)
assert.equal(tag3.gedeckelt, false)
})
test('Deckel gilt auch fuer unbezahlte und anonymisierte Bestellungen', async () => {
config.shop.loeschDeckel = 2
shop('s_m')
for (let i = 0; i < 5; i++) bestellung('s_m', { status: 'storniert', erstelltAt: vor(60), bezahltAt: null })
for (let i = 0; i < 3; i++) bestellung('s_m', { erstelltAt: '2020-01-01T10:00:00.000Z' })
const tag1 = await taeglicherJob(JETZT)
assert.equal(tag1.unbezahltGeloescht, 2)
assert.equal(tag1.bestellungenAnonymisiert, 2)
assert.equal(tag1.gedeckelt, true)
let t = 1
let letzte = tag1
while (letzte.gedeckelt && t < 10) letzte = await taeglicherJob(new Date(JETZT.getTime() + t++ * TAG))
assert.equal([...aw.docs('shopOrders').values()].filter((d) => d.status === 'storniert').length, 0)
assert.equal([...aw.docs('shopOrders').values()].filter((d) => !d.anonymisiertAt).length, 0)
})
// ------------------------------------------------------------------------------------------------ Schalter, Fehler
test('SHOP_ENABLED=off ohne Shop-Dokumente: der Tagesjob kehrt nach einer Abfrage zurueck; mit Shop-Dokumenten laeuft er (Fristen gelten weiter)', async () => {
config.shop.mode = 'off'
aw.resetListCalls()
assert.deepEqual(await taeglicherJob(JETZT), { aus: true })
assert.equal(aw.listCalls().length, 1, 'genau eine Abfrage')
assert.equal(aw.listCalls()[0].collection, 'shopConfig')
assert.deepEqual(await stuendlicherJob(JETZT), { aus: true })
assert.deepEqual(await minutenJob(JETZT), { aus: true })
shop('s_n')
const o = bestellung('s_n', { status: 'storniert', erstelltAt: vor(60), bezahltAt: null })
const erg = await taeglicherJob(JETZT)
assert.equal(erg.unbezahltGeloescht, 1)
assert.equal(aw.docs('shopOrders').has(o), false)
})
test('Fehler sind je Schritt und je Dokument abgesichert: ein kaputter Widerruf stoppt weder Bestellungen noch andere Shops', async (t) => {
const warn = t.mock.method(console, 'warn', () => {})
shop('s_o')
shop('s_p', { siteKey: KEY2 })
const w1 = widerruf('s_o', { eingangAt: '2020-01-01T10:00:00.000Z' })
const w2 = widerruf('s_o', { eingangAt: '2020-01-02T10:00:00.000Z' })
const o = bestellung('s_o', { erstelltAt: '2020-01-01T10:00:00.000Z' })
const w3 = widerruf('s_p', { eingangAt: '2020-01-01T10:00:00.000Z' })
aw.failNext({ collection: 'shopWiderrufe', method: 'PATCH', status: 500, message: 'Fehler fuer max@example.com' })
const erg = await taeglicherJob(JETZT)
assert.equal(erg.fehler, 1)
assert.equal(erg.widerrufeAnonymisiert, 2)
assert.ok(order(o).anonymisiertAt)
const geleert = [w1, w2, w3].filter((x) => !aw.docs('shopWiderrufe').get(x).email)
assert.equal(geleert.length, 2)
const logs = warn.mock.calls.map((c) => String(c.arguments[0])).join('\n')
assert.match(logs, /taeglicherJob: shopWiderrufe .* fehlgeschlagen/)
assert.ok(!logs.includes('Mustermann'), 'keine Kaeuferdaten im Log')
// der naechste Lauf holt das kaputte Dokument nach
assert.equal((await taeglicherJob(new Date(JETZT.getTime() + TAG))).widerrufeAnonymisiert, 1)
})
test('Fehler beim Loeschen eines Shops stoppt die anderen nicht und laesst den Vermerk weg', async (t) => {
t.mock.method(console, 'warn', () => {})
shop('s_q')
feature('s_q', { state: 'disabled', disabledAt: vor(100) })
bestellung('s_q')
shop('s_r', { siteKey: KEY2 })
feature('s_r', { siteKey: KEY2, projectId: 'p2', state: 'disabled', disabledAt: vor(100) })
bestellung('s_r')
aw.failNext({ collection: 'shopOrders', method: 'DELETE', status: 500, message: 'kaputt' })
const erg = await taeglicherJob(JETZT)
assert.equal(erg.fehler, 1)
assert.equal(erg.shopsGeloescht, 1)
const offen = ['s_q', 's_r'].filter((x) => aw.docs('shopConfig').has(x))
assert.equal(offen.length, 1)
assert.equal(aw.docs('portalFeatures').get(offen[0]).adminNote, undefined, 'kein Vermerk ohne vollstaendige Loeschung')
assert.equal((await taeglicherJob(new Date(JETZT.getTime() + TAG))).shopsGeloescht, 1)
assert.equal(aw.docs('shopConfig').size, 0)
})
test('ein Lesefehler beim Auflisten der Shops stoppt die Loeschung abgeschalteter Shops nicht', async (t) => {
t.mock.method(console, 'warn', () => {})
shop('s_s')
feature('s_s', { state: 'disabled', disabledAt: vor(100) })
aw.failNext({ collection: 'shopConfig', method: 'GET', status: 500, message: 'kaputt' })
const erg = await taeglicherJob(JETZT)
assert.equal(erg.fehler, 1)
assert.equal(erg.shopsGeloescht, 1)
})
// ------------------------------------------------------------------------------------------------ Stundenjob
test('Stundenjob raeumt nie beanspruchte Bilder nach 24 h auf', async () => {
const ordner = path.join(uploadDir, KEY)
mkdirSync(ordner, { recursive: true })
for (const [bildId, claimed] of [['alt1', false], ['alt2', true]]) {
writeFileSync(path.join(ordner, `${bildId}-l.webp`), 'x')
writeFileSync(path.join(ordner, `${bildId}-s.webp`), 'x')
writeFileSync(path.join(ordner, `${bildId}.json`), JSON.stringify({ claimed, createdAt: vor(3) }))
}
const erg = await stuendlicherJob(JETZT)
assert.equal(erg.bilderEntfernt, 1)
assert.equal(existsSync(path.join(ordner, 'alt1.json')), false)
assert.equal(existsSync(path.join(ordner, 'alt2.json')), true)
})
// ------------------------------------------------------------------------------------------------ Kundenloeschung
test('Kunde loeschen: Webshop wird abgeschaltet (Grund kunde_geloescht), geoeffnet=false, Daten bleiben fuer die 90 Tage', async () => {
aw.seed('customers', { $id: 'cs9', name: 'Weg', email: 'weg@example.invalid' })
aw.seed('websiteProjects', { $id: 'p9', customerId: 'cs9', projectName: 'Laden' })
aw.seed('shopConfig', { $id: 'f_p9_shop', customerId: 'cs9', projectId: 'p9', siteKey: KEY, geoeffnet: true })
aw.seed('portalFeatures', { $id: 'f_p9_shop', customerId: 'cs9', projectId: 'p9', featureKey: 'shop', siteKey: KEY, state: 'active', enabled: true })
const o = bestellung('f_p9_shop')
aw.seed('shopConfig', { $id: 'f_fremd', customerId: 'cs8', projectId: 'p8', siteKey: KEY2, geoeffnet: true })
await deleteCustomerWithPortalAccess('cs9')
assert.equal(aw.docs('customers').has('cs9'), false)
const f = aw.docs('portalFeatures').get('f_p9_shop')
assert.deepEqual([f.state, f.enabled, f.adminNote, f.disabledReason], ['disabled', false, 'Kunde gelöscht', 'kunde_geloescht'])
assert.ok(Math.abs(Date.parse(f.disabledAt) - Date.now()) < 5000)
assert.equal(aw.docs('shopConfig').get('f_p9_shop').geoeffnet, false)
assert.equal(aw.docs('shopConfig').get('f_fremd').geoeffnet, true, 'Shop eines anderen Kunden unberuehrt')
assert.equal(aw.docs('shopOrders').has(o), true, 'Daten bleiben bis zur 90-Tage-Frist')
// 89 Tage spaeter noch da, nach 91 Tagen vom Tagesjob geloescht
const frueh = new Date(Date.parse(f.disabledAt) + 89 * TAG)
assert.equal((await taeglicherJob(frueh)).shopsGeloescht, 0)
assert.equal(aw.docs('shopOrders').has(o), true)
const spaet = new Date(Date.parse(f.disabledAt) + 91 * TAG)
assert.equal((await taeglicherJob(spaet)).shopsGeloescht, 1)
assert.equal(aw.docs('shopOrders').has(o), false)
assert.equal(aw.docs('shopConfig').has('f_p9_shop'), false)
assert.equal(aw.docs('portalFeatures').get('f_p9_shop').state, 'disabled')
})
const kundeMitShop = (kunde, projekt) => {
aw.seed('customers', { $id: kunde, name: 'Weg', email: `${kunde}@example.invalid` })
aw.seed('websiteProjects', { $id: projekt, customerId: kunde, projectName: 'Laden' })
aw.seed('shopConfig', { $id: `f_${projekt}_shop`, customerId: kunde, projectId: projekt, siteKey: KEY, geoeffnet: true })
aw.seed('portalFeatures', { $id: `f_${projekt}_shop`, customerId: kunde, projectId: projekt, featureKey: 'shop', siteKey: KEY, state: 'active', enabled: true })
}
test('Kunde loeschen gelingt auch, wenn das Abschalten des Webshops scheitert (Fehler im Log); der Shop wird trotzdem geschlossen', async (t) => {
const log = t.mock.method(console, 'error', () => {})
kundeMitShop('cs10', 'p10')
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
await deleteCustomerWithPortalAccess('cs10')
assert.equal(aw.docs('customers').has('cs10'), false)
assert.ok(log.mock.calls.some((c) => /Webshop von Kunde cs10 nicht abgeschaltet/.test(String(c.arguments[0]))))
assert.equal((await sf.getFeatureDoc('cs10', 'p10', 'shop')).state, 'active', 'das Dokument bleibt wie es war')
assert.equal(aw.docs('shopConfig').get('f_p10_shop').geoeffnet, false)
})
test('Kunde loeschen gelingt auch, wenn das Schliessen des Webshops scheitert (Fehler im Log); die Funktion ist abgeschaltet', async (t) => {
const log = t.mock.method(console, 'error', () => {})
kundeMitShop('cs11', 'p11')
aw.failNext({ collection: 'shopConfig', method: 'PATCH', status: 500, message: 'kaputt' })
await deleteCustomerWithPortalAccess('cs11')
assert.equal(aw.docs('customers').has('cs11'), false)
assert.ok(log.mock.calls.some((c) => /Webshop von Kunde cs11 nicht geschlossen/.test(String(c.arguments[0]))))
assert.equal((await sf.getFeatureDoc('cs11', 'p11', 'shop')).state, 'disabled')
})
// ------------------------------------------------------------------------------------------------ Registrierung
test('server/index.js registriert Minuten- (75 s), Stunden- (130 s) und Tagesjob (200 s) mit unref', () => {
const quelle = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
assert.match(quelle, /from '\.\/services\/shop\/jobs\.js'/)
for (const [name, sekunden, takt] of [['shopMinuten', 75, '60 \\* 1000'], ['shopStunden', 130, '60 \\* 60 \\* 1000'], ['shopTages', 200, '24 \\* 60 \\* 60 \\* 1000']]) {
assert.match(quelle, new RegExp(`setTimeout\\(\\(\\) => \\{\\s*${name}\\(\\)\\s*setInterval\\(${name}, ${takt}\\)\\.unref\\(\\)\\s*\\}, ${sekunden} \\* 1000\\)\\.unref\\(\\)`), name)
}
})
test('Dokument bleibt lesbar: getDocument liefert das Funktionsdokument nach der Loeschung', async () => {
shop('s_z')
feature('s_z', { state: 'disabled', disabledAt: vor(100) })
await taeglicherJob(JETZT)
assert.equal((await getDocument(config.collections.portalFeatures, 's_z')).state, 'disabled')
})
+107
View File
@@ -0,0 +1,107 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { config } from '../server/config.js'
import { BESTAETIGUNG_TEXT, gewaehrleistungsHinweis } from '../server/services/shop/rechtstexte.js'
// Kanzlei-Paket Webshop (Task J2): docs/rechtliches/webshop-kanzlei-paket.md. Das Paket zitiert Texte, die im Programm stehen;
// dieser Test prueft, dass jedes Zitat wortgleich zur Quelle passt, dass F1 bis F29 vorhanden sind, dass die Fundstellen existieren
// und dass Paket, Einbauanleitung und Datenschutz-Absatz zur Entscheidung W5 (keine Abo-Kopplung im Programm) passen.
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r/g, '')
const norm = (text) => text.replace(/\s+/g, ' ').trim()
const zitat = (text) => text.split('\n').map((z) => `> ${z}`.trimEnd()).join('\n')
const PAKET = lies('docs/rechtliches/webshop-kanzlei-paket.md')
test('shop_paket_aufbau: Abschnitte 1 bis 7 und F1 bis F29 in Reihenfolge', () => {
let pos = -1
for (const h of ['## 1. Zweck und Umfang', '## 2. Produktablauf in Kurzform', '## 3. Texte in Endfassung', '## 4. Fragen an die Kanzlei', '## 5. Fundstellen im Code', '## 6. Nicht Teil der Kanzleiprüfung', '## 7. Checkliste für den Inhaber vor `SHOP_ENABLED=on`']) {
const p = PAKET.indexOf(`\n${h}\n`)
assert.ok(p > pos, `Überschrift fehlt oder steht falsch: ${h}`)
pos = p
}
for (let i = 1; i <= 29; i += 1) assert.equal(PAKET.split(new RegExp(`^### F${i}: `, 'm')).length - 1, 1, `F${i} genau einmal`)
assert.ok(!PAKET.includes('### F30:'))
// jede Frage hat Text und Frage
const bloecke = PAKET.split(/^### F\d+: /m).slice(1)
assert.equal(bloecke.length, 29)
for (const [i, b] of bloecke.entries()) assert.ok(b.includes('**Text.**') && b.includes('**Frage.**'), `F${i + 1}: Text und Frage`)
})
test('shop_paket_zitate: Haken, Knopf, Bestätigung und Gewährleistungshinweis stehen wörtlich im Programm', () => {
const haken = config.features.shop.consent.items
assert.equal(haken.length, 5)
for (const h of haken) assert.ok(PAKET.includes(`> ${h.text}`), `Haken ${h.id}`)
assert.ok(PAKET.includes(`> ${config.features.shop.consent.knopf}`))
assert.ok(PAKET.includes(`> ${BESTAETIGUNG_TEXT}`))
assert.ok(PAKET.includes(zitat(gewaehrleistungsHinweis().replace(/\n\n/g, '\n\n'))), 'Gewährleistungshinweis')
assert.equal(config.shop.fassung, '2026-10-05')
assert.ok(PAKET.includes('Fassung **2026-10-05**'))
assert.ok(PAKET.includes('299 €') && config.features.shop.priceLabel === '299 €')
})
test('shop_paket_widget_zitate: die Widget-Texte, die das Paket nennt, stehen im Widget', () => {
const widget = lies('public/embed/shop.js')
for (const s of [
'Zahlungspflichtig bestellen',
'Vertrag widerrufen',
'Widerruf bestätigen',
'Sie haben ein gesetzliches Widerrufsrecht, soweit unten für einzelne Artikel nichts anderes steht. Einzelheiten finden Sie in der Widerrufsbelehrung.',
'Hier können Sie Ihren Vertrag widerrufen. Bitte geben Sie Ihren Namen, die E-Mail-Adresse Ihrer Bestellung und möglichst die Bestellnummer an. Sie steht in Ihrer Bestätigungs-E-Mail.',
'Bitte prüfen Sie Ihre Angaben. Mit „Widerruf bestätigen“ erklären Sie den Widerruf Ihres Vertrags.',
'Name, Lieferadresse und Zahlung geben Sie im nächsten Schritt bei unserem Zahlungsdienstleister Stripe ein.',
'AGB lesen', 'Widerrufsbelehrung lesen', 'Versand & Zahlung lesen',
]) {
assert.ok(widget.includes(s), `Widget: ${s}`)
assert.ok(PAKET.includes(s), `Paket: ${s}`)
}
})
test('shop_paket_fundstellen: genannte Dateien existieren, Bezeichner stehen in der Datei', () => {
const pfade = new Set([...PAKET.matchAll(/`((?:server|public|docs|scripts)\/[A-Za-z0-9_./ -]+?\.(?:js|mjs|md|html|sh|webp|pdf))`/g)].map((m) => m[1]))
assert.ok(pfade.size > 20)
for (const p of pfade) assert.ok(existsSync(new URL(`../${p}`, import.meta.url)), `Datei fehlt: ${p}`)
const tabelle = PAKET.slice(PAKET.indexOf('## 5. Fundstellen im Code'), PAKET.indexOf('## 6. Nicht Teil'))
for (const zeile of tabelle.split('\n').filter((z) => z.startsWith('| ') && !z.startsWith('| Thema') && !z.startsWith('|---'))) {
const spalten = zeile.split('|').map((s) => s.trim())
const dateien = [...spalten[2].matchAll(/`([^`]+)`/g)].map((m) => m[1])
const namen = [...(spalten[3] || '').matchAll(/`([^`]+)`/g)].map((m) => m[1])
for (const n of namen) {
if (n.startsWith('Routen') || !/^[A-Za-z_]+$/.test(n)) continue
const gefunden = dateien.some((d) => !d.endsWith('/') && lies(d).includes(n))
assert.ok(gefunden, `Bezeichner ${n} nicht in ${dateien.join(', ')}`)
}
}
})
test('shop_paket_w5: Webshop ist im Programm nicht an ein Abo gekoppelt, Dokumente sagen es einheitlich', () => {
const lb = norm(lies('docs/rechtliches/leistungsbeschreibung-webshop.md'))
const avv = norm(lies('docs/rechtliches/avv-webshop.md'))
const einbau = norm(lies('docs/funktionen/shop-einbau.md'))
assert.ok(lb.includes('Zusatzbedingungen zum Vertrag über den Betrieb der Website'))
assert.ok(lb.includes('Endet der Vertrag über den Betrieb der Website, endet auch der Webshop; WEBklar schaltet ihn zum Vertragsende ab und informiert den Händler vorher per E-Mail.'))
assert.ok(lb.includes('Der Preis steht neben den Leistungen eines Abos: Die Einrichtung wird weder auf ein Stundenkontingent angerechnet noch damit bezahlt.'))
assert.ok(lb.includes('(6) Endet der Vertrag über den Betrieb der Website, bevor WEBklar den Webshop freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.'))
assert.ok(!/Hosting-Abo oder Wartungs-Abo/.test(lb.replace('im Hosting-Abo, im Wartungs-Abo oder aufgrund', '')), 'LB bindet nicht an ein bestimmtes Abo')
assert.ok(avv.includes('spätestens mit dem Ende des Vertrags, aufgrund dessen WEBklar die Website des Kunden betreibt'))
assert.ok(!avv.includes('Ende des Hosting-Abos oder Wartungs-Abos'))
assert.ok(einbau.includes('**nicht in der Software**') && einbau.includes('„Abschalten“') && einbau.includes('Leistungsbeschreibung Nr. 10 Abs. 6'))
assert.ok(norm(PAKET).includes('Das Programm prüft das **nicht**'))
// der Webshop liest im Programm keine Abo-Daten
for (const d of ['server/services/shop/schalter.js', 'server/services/shop/jobs.js', 'server/services/shop/bestellungen.js']) assert.doesNotMatch(lies(d), /aboStatus|aboAbgleich|subscriptions/, d)
})
test('shop_einbau_und_absatz: Einbauanleitung nennt Snippet, Widerrufslink, Impressum, Vorschau, Testkauf; Absatz ist Muster in Sie-Form', () => {
const einbau = lies('docs/funktionen/shop-einbau.md')
for (const s of ['data-webklar-shop="SITEKEY"', 'https://project.webklar.com/embed/shop.js', '/shop/<siteKey>/widerruf', 'Vertrag widerrufen', 'Impressum', 'docs/funktionen/shop-datenschutz-absatz.md', 'https://project.webklar.com/shop/<siteKey>`', '**1 €**', 'Erstatten', 'Ticket', 'Abschalten', '90 Tage', '299 €']) assert.ok(einbau.includes(s), `Einbauanleitung: ${s}`)
const absatz = lies('docs/funktionen/shop-datenschutz-absatz.md')
assert.ok(absatz.includes('**Muster – keine Rechtsberatung.**'))
for (const s of ['Stripe Payments Europe, Limited', 'EU-US Data Privacy Framework', 'Klarna', 'SEPA-Lastschrift', 'sessionStorage', '§ 25 Abs. 2 Nr. 2 TDDDG', 'drei Jahre nach dem Ende des Jahres der Bestellung', 'Handels- und Steuerrecht', 'Auftragsverarbeitung nach Art. 28 DSGVO', 'WEBklar GbR', 'Ihre Rechte']) assert.ok(absatz.includes(s), `Absatz: ${s}`)
assert.ok(!/\bdu\b|\bdein/i.test(absatz.slice(absatz.indexOf('## [Nummer]'))), 'Sie-Form')
})
test('shop_kanzlei_fragen_pointer: die Arbeitsliste verweist auf das Paket', () => {
const f = lies('docs/rechtliches/webshop-kanzlei-fragen.md')
assert.ok(f.includes('docs/rechtliches/webshop-kanzlei-paket.md') && f.length < 1000)
})
+374
View File
@@ -0,0 +1,374 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Shop-Kontext (C6): Laden per Projekt und siteKey, sicheres Parsen, 30-s-Speicher, Bereitschaft, Oeffnungs-Regel.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
},
datetimes: SHOP_DATETIMES,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const K = await import('../server/services/shop/kontext.js')
const { normalisiereProdukt } = await import('../server/services/shop/produkte.js')
const { speichereEinstellungen, STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_shop'
const SITE_KEY = 'abcdefghijklmnop'
const T0 = new Date('2026-10-05T12:00:00.000Z')
const nach = (s) => new Date(T0.getTime() + s * 1000)
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
kleinunternehmer: true,
lieferzeit: '2–4 Werktage',
shopname: 'Brot & Mehr',
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' },
}
const TEXTE = {
agb: 'AGB-Text',
widerrufsbelehrung: 'Widerruf-Text',
widerrufsformular: 'Formular-Text',
impressumUrl: 'https://brot.example/impressum',
datenschutzUrl: 'https://brot.example/datenschutz',
}
const hashFuer = (texte = TEXTE, einstellungen = EINSTELLUNGEN) => rechtstexteHash(texte, einstellungen)
const bestaetigt = (texte = TEXTE, hash = hashFuer(texte)) => ({ texte, hash, at: '2026-10-05T10:00:00.000Z', von: 'kunde@example.invalid' })
const featureDoc = (over = {}) => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true, ...over })
const shopDoc = (over = {}) => ({
$id: FEATURE,
customerId: KUNDE,
projectId: PROJEKT,
siteKey: SITE_KEY,
anfrageStatus: 'angenommen',
stripeAccountId: 'acct_test',
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }),
einstellungen: JSON.stringify(EINSTELLUNGEN),
rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: bestaetigt() }),
geoeffnet: true,
version: 3,
...over,
})
const artikel = (id, over = {}, produktOver = {}) => {
const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 450, ...produktOver }, { einstellungen: { kleinunternehmer: true } })
assert.equal(r.fehler, undefined, JSON.stringify(r.fehler))
return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt), ...over }
}
async function leere() {
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'websiteProjects']) {
for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id)
}
K.leereCache()
}
async function aufbau({ feature = {}, shop = {}, produkte = [artikel('a1')] } = {}) {
await leere()
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen' })
aw.seed('portalFeatures', featureDoc(feature))
if (shop !== null) aw.seed('shopConfig', shopDoc(shop))
for (const p of produkte) aw.seed('shopProducts', p)
}
beforeEach(() => K.leereCache())
test('ladeRechtstexte: Normalfall, leer, kaputt, alte Form', () => {
const roh = JSON.stringify({ entwurf: TEXTE, bestaetigt: bestaetigt() })
const r = K.ladeRechtstexte({ rechtstexte: roh })
assert.deepEqual(r.entwurf, TEXTE)
assert.deepEqual(r.bestaetigt.texte, TEXTE)
assert.equal(r.bestaetigt.hash, hashFuer())
const leer = { entwurf: { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' }, bestaetigt: null }
for (const rohText of [undefined, null, '', '{kaputt', '[]', '"x"', '42', JSON.stringify(TEXTE), JSON.stringify({ entwurf: 5, bestaetigt: 'ja' })]) {
assert.deepEqual(K.ladeRechtstexte({ rechtstexte: rohText }), leer, String(rohText))
}
assert.deepEqual(K.ladeRechtstexte(null), leer)
// bestaetigt ohne Hash oder ohne Texte zaehlt nicht; Nicht-Text-Felder werden leer
assert.equal(K.ladeRechtstexte({ rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE } }) }).bestaetigt, null)
assert.equal(K.ladeRechtstexte({ rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { hash: 'x' } }) }).bestaetigt, null)
assert.equal(K.ladeRechtstexte({ rechtstexte: JSON.stringify({ entwurf: { ...TEXTE, agb: 5 } }) }).entwurf.agb, '')
})
test('ladeShop: geparste Felder, kaputtes JSON wird Standard', () => {
const s = K.ladeShop(shopDoc())
assert.equal(s.id, FEATURE)
assert.equal(s.siteKey, SITE_KEY)
assert.equal(s.einstellungen.shopname, 'Brot & Mehr')
assert.equal(s.stripeStatus.charges_enabled, true)
assert.equal(s.stripeAccountId, 'acct_test')
assert.equal(s.geoeffnet, true)
assert.equal(s.anfrageStatus, 'angenommen')
assert.equal(s.version, 3)
const kaputt = K.ladeShop({ $id: 'x', einstellungen: '{kaputt', rechtstexte: '{{', stripeStatus: 'nope', geoeffnet: 'ja' })
assert.equal(kaputt.einstellungen.shopname, '')
assert.equal(kaputt.einstellungen.lieferzeit, STANDARD_EINSTELLUNGEN.lieferzeit)
assert.equal(kaputt.rechtstexte.bestaetigt, null)
assert.equal(kaputt.stripeStatus, null)
assert.equal(kaputt.geoeffnet, false)
assert.equal(kaputt.version, 0)
assert.equal(K.ladeShop({ $id: 'y', stripeStatus: '[1]' }).stripeStatus, null)
})
test('ladeShopFuerProjekt: eigenes Projekt, fremdes Projekt, ohne Funktion, ohne shopConfig', async () => {
await aufbau()
const r = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(r.doc.$id, FEATURE)
assert.equal(r.shop.id, FEATURE)
assert.equal(r.project.$id, PROJEKT)
const fremd = await K.ladeShopFuerProjekt('c_anderer', PROJEKT)
assert.deepEqual(fremd, { doc: null, shop: null, project: null })
assert.deepEqual(await K.ladeShopFuerProjekt(KUNDE, 'p_gibtsnicht'), { doc: null, shop: null, project: null })
await aufbau({ shop: null })
const ohneShop = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(ohneShop.doc.$id, FEATURE)
assert.equal(ohneShop.shop, null)
assert.equal(ohneShop.project.$id, PROJEKT)
await leere()
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen' })
const ohneFunktion = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(ohneFunktion.doc, null)
assert.equal(ohneFunktion.shop, null)
assert.equal(ohneFunktion.project.$id, PROJEKT)
})
test('shopPerSiteKey: active und disabled, sonst null', async () => {
await aufbau()
const ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
assert.equal(ctx.doc.$id, FEATURE)
assert.equal(ctx.shop.id, FEATURE)
assert.equal(ctx.project.$id, PROJEKT)
await aufbau({ feature: { state: 'disabled', enabled: false } })
const aus = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
assert.equal(aus.doc.state, 'disabled', 'disabled bleibt fuer den Widerruf erreichbar')
for (const state of ['off', 'ordered', 'setup']) {
await aufbau({ feature: { state, enabled: false } })
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: T0 }), null, state)
}
await aufbau({ feature: { featureKey: 'news' } })
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: T0 }), null, 'andere Funktion')
await aufbau({ shop: null })
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: T0 }), null, 'ohne shopConfig')
await aufbau()
for (const schlecht of ['', 'ABCDEFGHIJKLMNOP', 'abcdefghijklmno', 'abcdefghijklmnop1', 'abcdefghijklmn01', null, undefined, 42, {}]) {
assert.equal(await K.shopPerSiteKey(schlecht, { now: T0 }), null, String(schlecht))
}
assert.equal(await K.shopPerSiteKey('zzzzzzzzzzzzzzzz', { now: T0 }), null, 'unbekannter siteKey')
})
test('shopPerSiteKey: 30-Sekunden-Speicher mit fester Zeit, Fehlschlaege werden auch gemerkt', async () => {
await aufbau()
const a = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
await updateDocument(config.collections.shopConfig, FEATURE, { geoeffnet: false })
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: nach(29) }), a, 'innerhalb von 30 s dasselbe Objekt')
assert.equal(a.shop.geoeffnet, true)
const b = await K.shopPerSiteKey(SITE_KEY, { now: nach(30) })
assert.notEqual(b, a)
assert.equal(b.shop.geoeffnet, false)
await aufbau({ feature: { state: 'setup', enabled: false } })
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: T0 }), null)
await updateDocument(config.collections.portalFeatures, FEATURE, { state: 'active', enabled: true })
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: nach(10) }), null, 'null ist gemerkt')
assert.ok(await K.shopPerSiteKey(SITE_KEY, { now: nach(31) }))
})
test('leereCache(siteKey) und leereCache() verwerfen den Speicher', async () => {
await aufbau()
const a = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
await updateDocument(config.collections.shopConfig, FEATURE, { geoeffnet: false })
K.leereCache('aaaaaaaaaaaaaaaa')
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: nach(1) }), a, 'anderer siteKey: nichts passiert')
K.leereCache(SITE_KEY)
assert.equal((await K.shopPerSiteKey(SITE_KEY, { now: nach(2) })).shop.geoeffnet, false)
await updateDocument(config.collections.shopConfig, FEATURE, { geoeffnet: true })
K.leereCache()
assert.equal((await K.shopPerSiteKey(SITE_KEY, { now: nach(3) })).shop.geoeffnet, true)
})
test('Speichern der Einstellungen leert den Speicher dieses Shops (Hook), andere bleiben', async () => {
await aufbau()
const a = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
assert.equal(a.shop.einstellungen.lieferzeit, '2–4 Werktage')
// zweiter Shop, bleibt im Speicher
const KEY2 = 'bcdefghijklmnopq'
aw.seed('portalFeatures', featureDoc({ $id: 'f_p2_shop', projectId: 'p2', siteKey: KEY2 }))
aw.seed('shopConfig', shopDoc({ $id: 'f_p2_shop', projectId: 'p2', siteKey: KEY2 }))
const b = await K.shopPerSiteKey(KEY2, { now: T0 })
await speichereEinstellungen(FEATURE, { ...EINSTELLUNGEN, lieferzeit: '1–2 Werktage' })
const a2 = await K.shopPerSiteKey(SITE_KEY, { now: nach(1) })
assert.notEqual(a2, a)
assert.equal(a2.shop.einstellungen.lieferzeit, '1–2 Werktage')
assert.equal(await K.shopPerSiteKey(KEY2, { now: nach(1) }), b)
})
test('Eine Abfrage, die waehrend des Leerens lief, landet nicht im Speicher', async () => {
await aufbau()
const lauf = K.shopPerSiteKey(SITE_KEY, { now: T0 })
K.leereCache() // vor dem Ende der Abfrage
const erstes = await lauf
await updateDocument(config.collections.shopConfig, FEATURE, { geoeffnet: false })
const zweites = await K.shopPerSiteKey(SITE_KEY, { now: nach(1) })
assert.notEqual(zweites, erstes)
assert.equal(zweites.shop.geoeffnet, false)
})
test('shopPerSiteKey: ungueltiger Zeitpunkt wirft', async () => {
await assert.rejects(() => K.shopPerSiteKey(SITE_KEY, { now: new Date('kaputt') }), TypeError)
})
const punkt = (r, id) => r.punkte.find((p) => p.id === id)
test('bereitschaft: alles in Ordnung', async () => {
await aufbau()
const ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
const r = await K.bereitschaft(ctx) // Artikel werden geladen
assert.deepEqual(r.punkte.map((p) => p.id), ['einbau', 'stripe', 'einstellungen', 'rechtstexte', 'artikel'])
assert.equal(r.bereit, true)
assert.ok(r.punkte.every((p) => p.ok && typeof p.text === 'string' && p.text !== ''))
assert.equal(punkt(r, 'einbau').text, 'Der Shop ist eingebaut.')
assert.equal(await K.istGeoeffnet(ctx), true)
assert.equal(await K.istGeoeffnet(ctx, { produkte: [artikel('a1')] }), true)
})
test('bereitschaft: einbau', async () => {
await aufbau({ feature: { state: 'setup', enabled: false } })
const { doc, shop } = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
const r = await K.bereitschaft({ doc, shop })
assert.equal(punkt(r, 'einbau').ok, false)
assert.equal(punkt(r, 'einbau').text, 'Wir bauen den Shop gerade in deine Website ein.')
assert.equal(r.bereit, false)
assert.equal(await K.istGeoeffnet({ doc, shop }), false)
})
test('bereitschaft: stripe', async () => {
for (const stripeStatus of [null, '', '{kaputt', JSON.stringify({ charges_enabled: false }), JSON.stringify({ charges_enabled: 'true' })]) {
await aufbau({ shop: { stripeStatus } })
const { doc, shop } = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
const r = await K.bereitschaft({ doc, shop })
assert.equal(punkt(r, 'stripe').ok, false, String(stripeStatus))
assert.match(punkt(r, 'stripe').text, /^Bitte /)
assert.equal(await K.istGeoeffnet({ doc, shop }), false)
}
})
test('bereitschaft: einstellungen', async () => {
await aufbau({ shop: { einstellungen: JSON.stringify({ ...EINSTELLUNGEN, anbieter: { ...EINSTELLUNGEN.anbieter, email: '' } }) } })
const { doc, shop } = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
const r = await K.bereitschaft({ doc, shop })
assert.equal(punkt(r, 'einstellungen').ok, false)
assert.equal(await K.istGeoeffnet({ doc, shop }), false)
await aufbau({ shop: { einstellungen: '{kaputt' } })
const k = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(punkt(await K.bereitschaft(k), 'einstellungen').ok, false)
})
test('bereitschaft I-4: Steuerart nicht gewählt (null) oder Lieferzeit leer - Punkt einstellungen offen, Shop bleibt zu', async () => {
for (const luecke of [{ kleinunternehmer: null }, { lieferzeit: '' }]) {
await aufbau({ shop: { einstellungen: JSON.stringify({ ...EINSTELLUNGEN, ...luecke }) } })
const c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
const r = await K.bereitschaft(c)
assert.equal(punkt(r, 'einstellungen').ok, false, JSON.stringify(luecke))
assert.match(punkt(r, 'einstellungen').text, /Steuerart/)
assert.equal(r.bereit, false)
assert.equal(await K.istGeoeffnet(c), false)
}
await aufbau({ shop: { einstellungen: JSON.stringify({ ...EINSTELLUNGEN, kleinunternehmer: false }) } })
assert.equal(punkt(await K.bereitschaft(await K.ladeShopFuerProjekt(KUNDE, PROJEKT)), 'einstellungen').ok, true)
})
test('bereitschaft und Oeffnung: Rechtstexte offen, geaendert, bestaetigt', async () => {
// nie bestaetigt
await aufbau({ shop: { rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: null }) } })
let c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
let r = await K.bereitschaft(c)
assert.equal(punkt(r, 'rechtstexte').ok, false)
assert.equal(punkt(r, 'rechtstexte').text, 'Bitte bestätige deine Rechtstexte.')
assert.equal(await K.istGeoeffnet(c), false, 'nie bestaetigt => zu')
assert.equal(K.oeffentlicheRechtstexte(c), null)
// Entwurf unvollstaendig
await aufbau({ shop: { rechtstexte: '' } })
c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
r = await K.bereitschaft(c)
assert.equal(punkt(r, 'rechtstexte').ok, false)
assert.match(punkt(r, 'rechtstexte').text, /^Bitte ergänze deine Rechtstexte/)
// bestaetigt, Entwurf danach geaendert
const neu = { ...TEXTE, agb: 'Neue AGB' }
await aufbau({ shop: { rechtstexte: JSON.stringify({ entwurf: neu, bestaetigt: bestaetigt() }) } })
c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
r = await K.bereitschaft(c)
assert.equal(punkt(r, 'rechtstexte').ok, false)
assert.equal(punkt(r, 'rechtstexte').text, 'Bitte bestätige deine geänderten Rechtstexte.')
assert.equal(r.bereit, false)
assert.equal(await K.istGeoeffnet(c), true, 'frueher bestaetigt => bleibt offen')
assert.deepEqual(K.oeffentlicheRechtstexte(c), TEXTE, 'Kaeufer sehen die bestaetigte Fassung')
// Hash passt zum Entwurf
await aufbau({ shop: { rechtstexte: JSON.stringify({ entwurf: neu, bestaetigt: bestaetigt(neu) }) } })
c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(punkt(await K.bereitschaft(c), 'rechtstexte').ok, true)
// Hash passt nach geaenderten Einstellungen (Versand/Zahlung fliessen ein) nicht mehr
await aufbau({ shop: { einstellungen: JSON.stringify({ ...EINSTELLUNGEN, zahlungsarten: ['card', 'klarna'] }) } })
c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(punkt(await K.bereitschaft(c), 'rechtstexte').ok, false)
assert.equal(await K.istGeoeffnet(c), true)
})
test('bereitschaft und Oeffnung: Artikel', async () => {
const lauf = async (produkte) => {
await aufbau({ produkte })
const c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
return { r: await K.bereitschaft(c), offen: await K.istGeoeffnet(c) }
}
let e = await lauf([])
assert.equal(punkt(e.r, 'artikel').ok, false)
assert.equal(punkt(e.r, 'artikel').text, 'Bitte lege mindestens einen aktiven, lieferbaren Artikel an.')
assert.equal(e.offen, false)
e = await lauf([artikel('a1', { aktiv: false })])
assert.equal(punkt(e.r, 'artikel').ok, false, 'nur inaktive')
e = await lauf([artikel('a1', {}, { bestand: 0 })])
assert.equal(punkt(e.r, 'artikel').ok, false, 'ausverkauft')
assert.equal(e.offen, false)
e = await lauf([artikel('a1', { produkt: '{kaputt' })])
assert.equal(punkt(e.r, 'artikel').ok, false, 'unlesbar')
e = await lauf([artikel('a1', { aktiv: false }), artikel('a2', {}, { bestand: 5 })])
assert.equal(punkt(e.r, 'artikel').ok, true)
assert.equal(e.offen, true)
})
test('istGeoeffnet: Haendler hat geschlossen, Funktion disabled, uebergebene Artikel', async () => {
await aufbau({ shop: { geoeffnet: false } })
let c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(await K.istGeoeffnet(c), false)
assert.equal((await K.bereitschaft(c)).bereit, true, 'bereit, aber nicht geoeffnet')
await aufbau({ feature: { state: 'disabled', enabled: false } })
c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(await K.istGeoeffnet(c), false)
await aufbau()
c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(await K.istGeoeffnet(c, { produkte: [] }), false, 'uebergebene (leere) Liste gilt, es wird nicht nachgeladen')
assert.equal((await K.bereitschaft(c, { produkte: [] })).punkte.find((p) => p.id === 'artikel').ok, false)
})
+454
View File
@@ -0,0 +1,454 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Webshop E4: Mails des Shops (services/shop/mails.js). Käufermails laufen durch die Warteschlange des Terminplaners
// (termineOutbox), Händlermails gehen direkt an das Kundenkonto. Mails landen in der Senke, nichts verlässt den Prozess.
const aw = await startAppwriteMock({
schema: { termineOutbox: schemaKeys('termineOutbox'), shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv') },
datetimes: { ...TERMINE_DATETIMES, ...SHOP_DATETIMES },
defaultLimit: 25,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { gewaehrleistungsHinweis } = await import('../server/services/shop/rechtstexte.js')
const M = await import('../server/services/shop/mails.js')
const { archiviereRechtstexte, holeArchivierteRechtstexte, archivId } = await import('../server/services/shop/archiv.js')
const { normalisiereRechtstexte, MAX_AGB, MAX_WIDERRUFSBELEHRUNG, MAX_WIDERRUFSFORMULAR } = await import('../server/services/shop/rechtstexte.js')
const { anKaeufer, anHaendler, kaeuferWerte, haendlerK25, haendlerK26 } = M
after(() => setMailTransportForTests(null))
const JETZT = new Date('2026-10-05T12:03:00.000Z') // 14:03 Berlin
const SITE_KEY = 'abcdefghijklmnop'
const ABSENDER_VORHER = config.smtp.from
const BASIS_VORHER = config.publicBaseUrl
const einstellungen = (over = {}) => ({
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
kleinunternehmer: true,
lieferzeit: '2–4 Werktage',
shopname: 'Töpferei Muster',
anbieter: { name: 'Erika Muster', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: 'DE', email: 'info@toepferei.example', telefon: '', ustId: '' },
lieferzeit: '2–4 Werktage',
akzent: '#aa3300',
abholung: { aktiv: true, adresse: 'Werkstatt, Hauptstr. 1, 67722 Winnweiler', hinweis: 'Di–Fr 10–16 Uhr' },
...over,
})
const texte = (over = {}) => ({
agb: 'AGB Zeile 1\nAGB Zeile 2',
widerrufsbelehrung: 'Belehrung Zeile 1\nZeile 2',
widerrufsformular: 'An Töpferei Muster\nHiermit widerrufe ich',
impressumUrl: 'https://toepferei.example/impressum',
datenschutzUrl: 'https://toepferei.example/datenschutz',
...over,
})
const ctxMit = (e = einstellungen(), t = texte()) => ({
doc: { $id: 'f_shop01', customerId: 'c1', projectId: 'p1', featureKey: 'shop', siteKey: SITE_KEY },
shop: {
id: 'f_shop01',
customerId: 'c1',
projectId: 'p1',
siteKey: SITE_KEY,
einstellungen: e,
rechtstexte: { entwurf: texte(), bestaetigt: { texte: t, hash: 'h1', at: JETZT.toISOString(), von: 'c1' } },
},
})
const bestellung = (over = {}, snap = {}) => ({
$id: 'o_bestellung01',
bestellnummer: '26-K7QM2X',
kaeuferEmail: 'max@example.com',
bezahltAt: JETZT.toISOString(),
erstelltAt: new Date(JETZT.getTime() - 600000).toISOString(),
summeCent: 4140,
bestellung: JSON.stringify({
positionen: [
{ name: 'Tasse', varianteText: 'blau', menge: 2, summeCent: 2400, widerruf: 'gesetzlich' },
{ name: 'Teller', varianteText: '', menge: 1, summeCent: 1250, summeText: '12,50 €', widerruf: 'individuell' },
],
summen: { zwischensummeCent: 3650, versandCent: 490, gesamtCent: 4140, texte: { zwischensumme: '36,50 €', versand: '4,90 €', gesamt: '41,40 €', steuerHinweis: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' } },
lieferart: 'versand',
land: 'DE',
kaeufer: { name: 'Max Mustermann', email: 'max@example.com' },
lieferadresse: { name: 'Max Mustermann', line1: 'Musterweg 3', postal_code: '12345', city: 'Musterstadt', country: 'DE' },
zahlungsart: 'card',
...snap,
}),
...over,
})
let sink
let log
beforeEach(async () => {
for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(config.collections.termineOutbox, id)
for (const id of aw.docs('shopRechtstexteArchiv').keys()) await deleteDocument(config.collections.shopRechtstexteArchiv, id)
for (const id of aw.docs('customers').keys()) await deleteDocument(config.collections.customers, id)
resetOutboxForTests()
config.smtp.from = 'noreply@webklar.com'
config.publicBaseUrl = 'https://project.webklar.com'
sink = mailSink()
setMailTransportForTests(sink)
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
aw.seed('customers', { $id: 'c1', name: 'Erika Muster', email: 'erika@toepferei.example' })
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
config.smtp.from = ABSENDER_VORHER
config.publicBaseUrl = BASIS_VORHER
})
const eintraege = () => [...aw.docs('termineOutbox').values()]
const warnLog = () => log.warn.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))
// ---------------------------------------------------------------- Werte
test('kaeuferWerte: Kopf, Fuß, Positionen, Summen, Lieferadresse und Rechtstexte aus Schnappschuss und Shop', () => {
const w = kaeuferWerte(ctxMit(), bestellung(), JETZT)
// euro() setzt ein geschütztes Leerzeichen vor das €-Zeichen
for (const [schluessel, wert] of Object.entries(w)) w[schluessel] = wert.replaceAll('\u00a0', ' ')
assert.equal(w.farbe, '#aa3300')
assert.equal(w.betrieb_name, 'Töpferei Muster')
assert.equal(w.anbieter_zeile, 'Erika Muster, Hauptstr. 1, 67722 Winnweiler, info@toepferei.example')
assert.equal(w.widerruf_url, `https://project.webklar.com/shop/${SITE_KEY}/widerruf`)
assert.equal(w.impressum_url, 'https://toepferei.example/impressum')
assert.equal(w.datenschutz_url, 'https://toepferei.example/datenschutz')
assert.equal(w.bestellnummer, '26-K7QM2X')
assert.equal(w.datum, '05.10.2026')
assert.equal(w.positionen, '2 × Tasse (blau) – 24,00 €\n1 × Teller – 12,50 €')
assert.deepEqual([w.zwischensumme, w.versand, w.gesamt], ['36,50 €', '4,90 €', '41,40 €'])
assert.equal(w.steuer_hinweis, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
assert.equal(w.lieferart_text, 'Versand nach Deutschland')
assert.equal(w.lieferadresse, 'Max Mustermann\nMusterweg 3\n12345 Musterstadt')
assert.equal(w.abholort, 'Werkstatt, Hauptstr. 1, 67722 Winnweiler')
assert.equal(w.lieferzeit, '2–4 Werktage')
assert.equal(w.zahlungsart, 'Kreditkarte, Apple Pay, Google Pay')
assert.equal(w.ausschluss_text, 'Teller: Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt')
assert.equal(w.agb, 'AGB Zeile 1\nAGB Zeile 2')
assert.equal(w.widerrufsbelehrung, 'Belehrung Zeile 1\nZeile 2')
assert.equal(w.widerrufsformular, 'An Töpferei Muster\nHiermit widerrufe ich')
assert.equal(w.gewaehrleistung, gewaehrleistungsHinweis())
})
test('kaeuferWerte: Links fallen ohne Impressum-/Datenschutz-Adresse auf den Shop zurück, ungültige Farbe auf Schwarz, ohne Bestellung "–"', () => {
const w = kaeuferWerte(ctxMit(einstellungen({ akzent: 'red;x' }), texte({ impressumUrl: '', datenschutzUrl: '' })), null, JETZT)
assert.equal(w.farbe, '#111111')
assert.equal(w.impressum_url, `https://project.webklar.com/shop/${SITE_KEY}`)
assert.equal(w.datenschutz_url, `https://project.webklar.com/shop/${SITE_KEY}`)
assert.equal(w.bestellnummer, '–')
assert.equal(w.positionen, '–')
assert.equal(w.lieferadresse, '–')
assert.equal(w.datum, '05.10.2026', 'ohne Bestellung gilt das übergebene Datum')
})
test('kaeuferWerte I-1: Bestelldatum ist der Tag der Bestellung, Zahlungsdatum der des Zahlungseingangs (SEPA Tage später)', () => {
const w = kaeuferWerte(ctxMit(), bestellung({ erstelltAt: '2026-10-05T10:00:00.000Z', bezahltAt: '2026-10-08T08:30:00.000Z' }), JETZT)
assert.equal(w.datum, '05.10.2026')
assert.equal(w.zahlungsdatum, '08.10.2026')
const offen = kaeuferWerte(ctxMit(), bestellung({ bezahltAt: undefined }), JETZT)
assert.equal(offen.zahlungsdatum, '05.10.2026', 'ohne Zahlung gilt das Bestelldatum')
})
test('kaeuferWerte M-5: Telefonnummer des Anbieters steht in der Anbieterzeile, wenn vorhanden', () => {
const mit = kaeuferWerte(ctxMit(einstellungen({ anbieter: { ...einstellungen().anbieter, telefon: '0 63 02 / 12 34' } })), bestellung(), JETZT)
assert.equal(mit.anbieter_zeile, 'Erika Muster, Hauptstr. 1, 67722 Winnweiler, info@toepferei.example, Tel. 0 63 02 / 12 34')
})
// ---------------------------------------------------------------- Käufermails
test('G09: wird mit Shopname als Absender und Antwort an den Händler eingereiht, kein BCC, Rechtstexte stehen in der Mail', async () => {
const erg = await anKaeufer('G09', ctxMit(), bestellung(), {}, JETZT)
assert.equal(erg.ok, true)
await warteAufVersand()
const [doc] = eintraege()
assert.equal(doc.art, 'G09')
assert.equal(doc.featureId, 'f_shop01')
assert.equal(doc.bookingId, 'o_bestellung01')
assert.equal(doc.fromName, 'Töpferei Muster')
assert.equal(doc.replyTo, 'info@toepferei.example')
assert.equal(doc.status, 'gesendet')
assert.equal(doc.an, '', 'nach dem Versand ist die Adresse gelöscht')
assert.equal(sink.sent.length, 1)
const mail = sink.sent[0]
assert.deepEqual(mail.to.map((t) => t.address), ['max@example.com'])
assert.ok(!mail.bcc, 'kein BCC')
assert.equal(mail.from.name, 'Töpferei Muster')
assert.equal(mail.from.address, 'noreply@webklar.com')
assert.equal(mail.replyTo[0].address, 'info@toepferei.example')
assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X bei Töpferei Muster')
for (const teil of ['2 × Tasse (blau) – 24,00 €', '41,40 €', 'Max Mustermann', 'Musterweg 3', 'Vom Widerruf ausgeschlossen', 'AGB Zeile 2', 'Belehrung Zeile 1', 'Hiermit widerrufe ich', 'GESETZLICHE GEWÄHRLEISTUNG', 'Vertrag widerrufen']) {
assert.ok(mail.text.includes(teil), teil)
}
assert.match(mail.html, new RegExp(`href="https://project\\.webklar\\.com/shop/${SITE_KEY}/widerruf"`))
assert.doesNotMatch(mail.text, /Abholort/)
assert.deepEqual(warnLog(), [], 'keine Platzhalter-Warnung der Vorlage')
})
test('G15: Eingang der Bestellung geht an die übergebene Adresse (noch keine Käuferadresse an der Bestellung), ohne Rechtstexte und ohne Vertragsschluss', async () => {
const offen = bestellung({ kaeuferEmail: undefined, bezahltAt: undefined, status: 'offen' })
const erg = await anKaeufer('G15', ctxMit(), offen, { an: 'sepa@example.com' }, JETZT)
assert.equal(erg.ok, true)
await warteAufVersand()
const [doc] = eintraege()
assert.equal(doc.art, 'G15')
assert.equal(doc.bookingId, 'o_bestellung01')
assert.equal(sink.sent.length, 1)
const mail = sink.sent[0]
assert.equal(mail.to[0].address, 'sepa@example.com')
assert.equal(mail.subject, 'Wir haben Ihre Bestellung 26-K7QM2X erhalten')
assert.match(mail.text, /noch in Bearbeitung/)
assert.match(mail.text, /2 × Tasse \(blau\) – 24,00/)
assert.doesNotMatch(mail.text, /Kaufvertrag ist damit geschlossen|AGB Zeile 1|Belehrung Zeile 1/)
})
test('G09 Abholung: Abholort statt Lieferadresse', async () => {
const erg = await anKaeufer('G09', ctxMit(), bestellung({}, { lieferart: 'abholung', lieferadresse: null, land: null, positionen: [{ name: 'Tasse', menge: 1, summeCent: 1200, widerruf: 'gesetzlich' }] }), {}, JETZT)
assert.equal(erg.ok, true)
await warteAufVersand()
const text = sink.sent[0].text
assert.match(text, /Lieferart: Abholung/)
assert.match(text, /Abholort: Werkstatt, Hauptstr\. 1, 67722 Winnweiler/)
assert.doesNotMatch(text, /Lieferadresse|Hinweis zum Widerrufsrecht/)
assert.deepEqual(warnLog(), [])
})
/** Zeilen mit Satzzeichen und Sonderzeichen wie in echten Rechtstexten, genau n Zeichen lang. */
const textMitLaenge = (n, zeile) => {
let t = ''
while (t.length < n) t += `${zeile}\n`
return t.slice(0, n)
}
test('G09 I-7: Texte in Höchstlänge passen mit Volltext in die Warteschlange (html 100000, text 50000), nie ein Link statt der AGB', async () => {
const zeile = 'Ein Satz der Geschäftsbedingungen mit Komma, Klammern (wie hier) & einem „Zitat“ – und Zahlen 1.234,56 €.'
const t = texte({
agb: textMitLaenge(MAX_AGB, zeile),
widerrufsbelehrung: textMitLaenge(MAX_WIDERRUFSBELEHRUNG, zeile),
widerrufsformular: textMitLaenge(MAX_WIDERRUFSFORMULAR, zeile),
})
const geprueft = normalisiereRechtstexte({ ...t })
assert.equal(geprueft.fehler, undefined, 'Höchstlänge wird vom Speichern akzeptiert')
const viele = Array.from({ length: 40 }, (_, i) => ({ name: `Artikel Nummer ${i + 1} mit langem Namen`, varianteText: 'Farbe: blau, Größe: M', menge: 3, summeCent: 12345, widerruf: i % 2 ? 'gesetzlich' : 'verderblich' }))
const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung({}, { positionen: viele }), {}, JETZT)
assert.equal(erg.ok, true)
const doc = eintraege()[0]
assert.ok(doc.html.length <= 100000 && doc.text.length <= 50000, `${doc.html.length}/${doc.text.length}`)
assert.equal(doc.text.split('Ein Satz der Geschäftsbedingungen').length - 1 >= 300, true, 'alle Texte stehen vollständig in der Mail')
assert.ok(doc.text.includes(t.agb.slice(-200).trim().split('\n').pop().slice(0, 30)), 'Ende der AGB')
assert.doesNotMatch(doc.text, /ansicht=agb|vollständigen AGB finden Sie/)
})
test('G09 I-7: zu lang für die Warteschlange ⇒ { ok: false } ohne Wurf, ohne Eintrag und ohne Link-Ersatz', async () => {
const t = texte({ agb: 'a&b<c>\n'.repeat(4000), widerrufsbelehrung: 'Belehrung Satz.\n'.repeat(500) })
const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung(), {}, JETZT)
assert.deepEqual(erg, { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' })
assert.equal(eintraege().length, 0)
assert.equal(sink.sent.length, 0)
assert.match(warnLog().join('\n'), /zu lang für die Warteschlange/)
})
test('G09 I-6: zeigt die archivierten Rechtstexte zum Hash der Bestellung, nicht die aktuell bestätigten', async () => {
const HASH = 'a'.repeat(64)
await archiviereRechtstexte({
shopId: 'f_shop01', customerId: 'c1', hash: HASH, at: JETZT.toISOString(), von: 'Erika',
texte: { agb: 'ALTE AGB', widerrufsbelehrung: 'ALTE BELEHRUNG', widerrufsformular: 'ALTES FORMULAR', impressumUrl: 'https://alt.example/impressum', datenschutzUrl: 'https://alt.example/datenschutz', versandUndZahlung: 'x', gewaehrleistung: 'ALTER GEWÄHRLEISTUNGSTEXT' },
})
const erg = await anKaeufer('G09', ctxMit(), bestellung({}, { rechtstexteHash: HASH }), {}, JETZT)
assert.equal(erg.ok, true)
const doc = eintraege()[0]
for (const teil of ['ALTE AGB', 'ALTE BELEHRUNG', 'ALTES FORMULAR', 'ALTER GEWÄHRLEISTUNGSTEXT']) assert.ok(doc.text.includes(teil), teil)
assert.doesNotMatch(doc.text, /AGB Zeile 1|Belehrung Zeile 1/)
assert.match(doc.html, /href="https:\/\/alt\.example\/impressum"/)
assert.deepEqual(warnLog(), [])
})
test('G09 I-6: fehlt die Fassung im Archiv, gilt mit Warnung die aktuell bestätigte; ohne Hash an der Bestellung ohne Warnung', async () => {
const erg = await anKaeufer('G09', ctxMit(), bestellung({}, { rechtstexteHash: 'b'.repeat(64) }), {}, JETZT)
assert.equal(erg.ok, true)
assert.match(eintraege()[0].text, /AGB Zeile 1/)
assert.match(warnLog().join('\n'), /fehlen im Archiv/)
assert.doesNotMatch(warnLog().join('\n'), /max@example\.com/)
})
test('Archiv I-6: Dokument-ID r + 35 Hex, ein zweites Anlegen derselben Fassung ist kein Fehler, fremder Shop findet sie nicht', async () => {
const HASH = 'c'.repeat(64)
assert.match(archivId('f_shop01', HASH), /^r[0-9a-f]{35}$/)
assert.notEqual(archivId('f_shop01', HASH), archivId('f_shop02', HASH))
const p = { shopId: 'f_shop01', customerId: 'c1', hash: HASH, at: JETZT.toISOString(), von: 'Erika', texte: { agb: 'A' } }
assert.deepEqual(await archiviereRechtstexte(p), { angelegt: true })
assert.deepEqual(await archiviereRechtstexte(p), { angelegt: false })
assert.deepEqual(await holeArchivierteRechtstexte('f_shop01', HASH), { agb: 'A' })
assert.equal(await holeArchivierteRechtstexte('f_shop02', HASH), null)
assert.equal(await holeArchivierteRechtstexte('f_shop01', 'd'.repeat(64)), null)
const doc = [...aw.docs('shopRechtstexteArchiv').values()][0]
assert.deepEqual([doc.shopId, doc.customerId, doc.hash, doc.bestaetigtVon], ['f_shop01', 'c1', HASH, 'Erika'])
})
test('G10: Versanddienst und Sendungsnummer (Block sendung); ohne beide kein Block', async () => {
assert.equal((await anKaeufer('G10', ctxMit(), bestellung(), { versanddienst: 'DHL', sendungsnummer: '00340434' }, JETZT)).ok, true)
assert.equal((await anKaeufer('G10', ctxMit(), bestellung({ $id: 'o_zwei' }), {}, JETZT)).ok, true)
await warteAufVersand()
const [mit, ohne] = sink.sent
assert.equal(mit.subject, 'Ihre Bestellung 26-K7QM2X wurde versendet')
assert.match(mit.text, /Versanddienst: DHL/)
assert.match(mit.text, /Sendungsnummer: 00340434/)
assert.doesNotMatch(ohne.text, /Sendungsnummer/)
})
test('G11: Abholort und Hinweis des Händlers, ersatzweise ein Standardhinweis', async () => {
await anKaeufer('G11', ctxMit(), bestellung(), {}, JETZT)
const ohneHinweis = einstellungen({ abholung: { aktiv: true, adresse: 'Werkstatt', hinweis: '' } })
await anKaeufer('G11', ctxMit(ohneHinweis), bestellung({ $id: 'o_zwei' }), {}, JETZT)
await warteAufVersand()
const texte11 = sink.sent.map((m) => m.text)
assert.match(texte11[0], /Abholort: Werkstatt, Hauptstr\. 1/)
assert.match(texte11[0], /Hinweis: Di–Fr 10–16 Uhr/)
assert.match(texte11[1], /Hinweis: Bitte bringen Sie Ihre Bestellnummer mit\./)
assert.equal(sink.sent[0].subject, 'Ihre Bestellung 26-K7QM2X ist abholbereit')
})
test('G12: ohne passende Bestellung, an die Adresse der Erklärung, mit Datum und Uhrzeit', async () => {
const erg = await anKaeufer('G12', ctxMit(), null, { an: 'kaeufer@example.com', bookingId: 'w_widerruf01', name: 'Kai Käufer', bestellnummer: '26-ZZZZZZ', eingang: JETZT, nachricht: 'Ich widerrufe.' }, JETZT)
assert.equal(erg.ok, true)
assert.equal(erg.eintrag.bookingId, 'w_widerruf01')
await warteAufVersand()
const mail = sink.sent[0]
assert.deepEqual(mail.to.map((t) => t.address), ['kaeufer@example.com'])
assert.equal(mail.subject, 'Eingangsbestätigung Ihres Widerrufs bei Töpferei Muster')
assert.match(mail.text, /Guten Tag Kai Käufer,/)
assert.match(mail.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/)
assert.match(mail.text, /Bestellnummer: 26-ZZZZZZ/)
assert.match(mail.text, /Ihre Erklärung: Ich widerrufe\./)
assert.deepEqual(warnLog(), [])
})
test('G12 und K26 ohne Bestellnummer: „–“ statt leerer Stelle', async () => {
const erg = await anKaeufer('G12', ctxMit(), null, { an: 'kaeufer@example.com', bookingId: 'w_widerruf02', name: 'Kai Käufer', bestellnummer: '', eingang: JETZT, nachricht: 'Bestellt am 01.10., Tasse blau' }, JETZT)
assert.equal(erg.ok, true)
const k = haendlerK26(ctxMit(), { name: 'Kai', email: 'kai@example.com', bestellnummer: '', nachricht: 'Bestellt am 01.10., Tasse blau', eingang: JETZT })
assert.equal(k.values.bestellnummer, '–')
assert.equal((await anHaendler('K26', ctxMit(), k.values, k.blocks)).ok, true)
await warteAufVersand()
assert.match(sink.sent[0].text, /Bestellnummer: –/)
assert.match(sink.sent[1].text, /Bestellnummer: –/)
})
test('G13: erstatteter Betrag in Euro, Grund nur wenn angegeben; ohne Betrag { ok: false }', async () => {
assert.equal((await anKaeufer('G13', ctxMit(), bestellung(), { betragCent: 1250, grund: 'Artikel beschädigt' }, JETZT)).ok, true)
assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_zwei' }), { betragCent: 4140 }, JETZT)).ok, true)
assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_drei' }), {}, JETZT)).ok, false)
assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_vier' }), { betragCent: 12.5 }, JETZT)).ok, false)
await warteAufVersand()
assert.equal(sink.sent.length, 2)
assert.match(sink.sent[0].text, /Erstatteter Betrag: 12,50 €/)
assert.match(sink.sent[0].text, /Grund: Artikel beschädigt/)
assert.match(sink.sent[1].text, /Erstatteter Betrag: 41,40 €/)
assert.doesNotMatch(sink.sent[1].text, /Grund:/)
assert.match(sink.sent[1].text, /Die Gutschrift erfolgt über die ursprüngliche Zahlungsart\./)
})
test('G14: Zahlung fehlgeschlagen, nichts berechnet', async () => {
assert.equal((await anKaeufer('G14', ctxMit(), bestellung(), {}, JETZT)).ok, true)
await warteAufVersand()
assert.equal(sink.sent[0].subject, 'Ihre Bestellung 26-K7QM2X wurde nicht abgeschlossen')
assert.match(sink.sent[0].text, /es wurde nichts berechnet/)
})
test('anKaeufer wirft nie: unbekannte Art, fehlende Adresse und Speicherfehler ergeben { ok: false }', async () => {
assert.equal((await anKaeufer('G01', ctxMit(), bestellung(), {}, JETZT)).ok, false)
assert.equal((await anKaeufer('G09', ctxMit(), bestellung({ kaeuferEmail: '' }), {}, JETZT)).ok, false)
assert.equal((await anKaeufer('G09', ctxMit(), bestellung(), {}, new Date('kaputt'))).ok, false)
aw.failNext({ collection: 'termineOutbox', method: 'POST', status: 500, message: 'Nachbau kaputt' })
const erg = await anKaeufer('G09', ctxMit(), bestellung(), {}, JETZT)
assert.equal(erg.ok, false)
assert.equal(eintraege().length, 0)
assert.equal(sink.sent.length, 0)
})
test('Käufermails laufen in der Klasse normal (keine zusätzlichen notEqual in der Abfrage)', async () => {
const { mailKlasse } = await import('../server/services/termine/outbox.js')
assert.deepEqual(['G09', 'G10', 'G11', 'G12', 'G13', 'G14'].map(mailKlasse), ['normal', 'normal', 'normal', 'normal', 'normal', 'normal'])
})
// ---------------------------------------------------------------- Händlermails
test('K25: geht an das Kundenkonto, ohne BCC ans Team, mit Käufer, Positionen und Portal-Link', async () => {
const { values, blocks } = haendlerK25(ctxMit(), bestellung())
assert.deepEqual(blocks, { ueberverkauft: false, sepa: false })
const erg = await anHaendler('K25', ctxMit(), values, blocks)
assert.deepEqual(erg, { ok: true })
assert.equal(sink.sent.length, 1)
const mail = sink.sent[0]
assert.deepEqual(mail.to.map((t) => t.address), ['erika@toepferei.example'])
assert.ok(!mail.bcc, 'kein BCC ans Team')
assert.equal(mail.subject, 'Neue Bestellung 26-K7QM2X in Deinem Webshop')
assert.match(mail.text, /Hallo Erika Muster/)
assert.match(mail.text, /Töpferei Muster/)
assert.match(mail.text, /Käufer: Max Mustermann/)
assert.match(mail.text, /Gesamt: 41,40 €/)
assert.match(mail.html.replaceAll('\u00a0', ' '), /2 × Tasse \(blau\) – 24,00 €<br>1 × Teller – 12,50 €/)
assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/dashboard\.html#webshop\?projekt=p1"/)
assert.doesNotMatch(mail.text, /Achtung|Lastschrift/)
assert.deepEqual(warnLog(), [])
})
test('K25: Blöcke ueberverkauft und sepa', async () => {
const { values, blocks } = haendlerK25(ctxMit(), bestellung({}, { zahlungsart: 'sepa_debit' }), { ueberverkauft: true })
assert.deepEqual(blocks, { ueberverkauft: true, sepa: true })
assert.equal((await anHaendler('K25', ctxMit(), values, blocks)).ok, true)
assert.match(sink.sent[0].text, /Achtung: Bestand reichte nicht/)
assert.match(sink.sent[0].text, /Zahlung per Lastschrift/)
})
test('K26: Widerruf mit Eingang in Berliner Zeit und Erklärung, ohne BCC; ohne Erklärung kein Block', async () => {
const widerruf = { name: 'Kai <K>', email: 'kai@example.com', bestellnummer: '26-ZZZZZZ', nachricht: 'Ich widerrufe.', eingangAt: JETZT.toISOString() }
const { values, blocks } = haendlerK26(ctxMit(), widerruf)
assert.equal(values.eingang, '05.10.2026, 14:03 Uhr')
assert.deepEqual(blocks, { nachricht: true })
assert.equal((await anHaendler('K26', ctxMit(), values, blocks)).ok, true)
const ohne = haendlerK26(ctxMit(), { ...widerruf, nachricht: ' ' })
assert.deepEqual(ohne.blocks, { nachricht: false })
assert.equal((await anHaendler('K26', ctxMit(), ohne.values, ohne.blocks)).ok, true)
const [mit, kurz] = sink.sent
assert.ok(!mit.bcc)
assert.equal(mit.subject, 'Widerruf in Deinem Webshop eingegangen')
assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/)
assert.match(mit.text, /Käufer: Kai <K>/)
assert.match(mit.text, /E-Mail: kai@example\.com/)
assert.match(mit.text, /Erklärung: Ich widerrufe\./)
assert.match(mit.html, /Kai &lt;K&gt;/)
assert.doesNotMatch(kurz.text, /Erklärung:/)
})
test('anHaendler wirft nie: Kunde fehlt oder ohne E-Mail, Versandfehler ⇒ { ok: false } und Warnung ohne Adresse', async () => {
const { values, blocks } = haendlerK25(ctxMit(), bestellung())
const fremd = ctxMit()
fremd.shop.customerId = 'c_gibtsnicht'
assert.equal((await anHaendler('K25', fremd, values, blocks)).ok, false)
aw.seed('customers', { $id: 'c_ohne', name: 'Ohne', email: '' })
const ohneMail = ctxMit()
ohneMail.shop.customerId = 'c_ohne'
assert.equal((await anHaendler('K25', ohneMail, values, blocks)).ok, false)
setMailTransportForTests({
async sendMail() {
throw new Error('SMTP erika@toepferei.example kaputt')
},
})
assert.deepEqual(await anHaendler('K25', ctxMit(), values, blocks), { ok: false, fehler: 'Die E-Mail an den Händler konnte nicht gesendet werden.' })
assert.equal(sink.sent.length, 0)
assert.doesNotMatch(warnLog().join('\n'), /erika@toepferei\.example/, 'keine Adresse im Log')
assert.ok(warnLog().some((zeile) => /K25 nicht gesendet/.test(zeile)))
})
+403
View File
@@ -0,0 +1,403 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { berechne } from '../server/services/shop/preise.js'
import { euro } from '../server/services/shop/geld.js'
const basis = {
aktiv: true, kurz: '', beschreibung: '', steuersatz: 19, grundpreis: null, bestand: null, varianten: null,
bilder: [], lieferart: 'beides', widerruf: 'gesetzlich',
}
const produkt = (id, extra = {}) => ({ ...basis, id, name: `Artikel ${id}`, preisCent: 1000, ...extra })
const karte = (...liste) => new Map(liste.map((p) => [p.id, p]))
const shirt = produkt('shirt', {
name: 'T-Shirt',
preisCent: null,
varianten: {
name: 'Größe',
werte: [
{ id: 'sizeaaaa', name: 'M', preisCent: 2000, bestand: 5 },
{ id: 'sizebbbb', name: 'L', preisCent: 2200, bestand: null },
],
},
})
const einstellungen = (extra = {}) => ({
kleinunternehmer: false,
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: null },
abholung: { aktiv: true, adresse: 'Weg 1', hinweis: '' },
mindestbestellCent: null,
...extra,
})
const rechne = (args) => berechne({ lieferart: 'versand', land: 'DE', einstellungen: einstellungen(), ...args })
const eine = (p, menge = 1, varianteId) => [{ produktId: p.id, menge, ...(varianteId ? { varianteId } : {}) }]
test('einfacher Warenkorb: Summen, Texte, Versand und Steuer', () => {
const a = produkt('a', { preisCent: 1190 })
const r = rechne({ produkte: karte(a), positionen: eine(a, 2) })
assert.equal(r.ok, true)
assert.equal(r.zwischensummeCent, 2380)
assert.equal(r.versandCent, 490)
assert.equal(r.gesamtCent, 2870)
assert.deepEqual(r.positionen[0], {
produktId: 'a', varianteId: null, name: 'Artikel a', varianteText: '', menge: 2, einzelCent: 1190, summeCent: 2380,
steuersatz: 19, widerruf: 'gesetzlich', einzelText: euro(1190), summeText: euro(2380),
})
assert.equal(r.texte.zwischensumme, euro(2380))
assert.equal(r.texte.versand, euro(490))
assert.equal(r.texte.gesamt, euro(2870))
// 2870 * 19 / 119 = 458,23 => 458
assert.deepEqual(r.steuer, [{ satz: 19, betragCent: 458, text: `inkl. 19 % USt: ${euro(458)}` }])
assert.equal(r.texte.steuerHinweis, `inkl. 19 % USt: ${euro(458)}`)
assert.equal(r.lieferart, 'versand')
assert.equal(r.land, 'DE')
})
test('gemischte Steuersaetze: Text mit absteigenden Saetzen, 0 % liefert keinen Eintrag', () => {
const a = produkt('a', { preisCent: 2000, steuersatz: 19 })
const b = produkt('b', { preisCent: 700, steuersatz: 7 })
const c = produkt('c', { preisCent: 500, steuersatz: 0 })
const e = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: 1 } })
const r = berechne({ produkte: karte(a, b, c), positionen: [...eine(b), ...eine(c), ...eine(a)], lieferart: 'versand', land: 'DE', einstellungen: e })
assert.equal(r.versandCent, 0)
assert.equal(r.texte.versand, 'kostenlos')
assert.deepEqual(r.steuer.map((s) => [s.satz, s.betragCent]), [[19, 319], [7, 46]])
assert.equal(r.texte.steuerHinweis, `inkl. 19 % USt: ${euro(319)}; inkl. 7 % USt: ${euro(46)}`)
assert.deepEqual(r.positionen.map((p) => p.produktId), ['b', 'c', 'a'], 'Reihenfolge der Eingabe bleibt')
})
test('Steuer wird je Satz ueber die Summe gerundet, nicht je Position', () => {
// Einzeln: 3 * round(0,119 * ... ) waere 3 * 0 ; ueber die Summe: 3 * 10 = 30 Cent brutto => 30*19/119 = 4,79 => 5
const a = produkt('a', { preisCent: 10 })
const fueller = produkt('fueller', { preisCent: 100, steuersatz: 0 }) // hebt den Gesamtbetrag ueber 0,50 EUR, ohne Steuer
const e = einstellungen({ abholung: { aktiv: true, adresse: 'x', hinweis: '' } })
const r = berechne({ produkte: karte(a, fueller), positionen: [...eine(a, 3), ...eine(fueller)], lieferart: 'abholung', einstellungen: e })
assert.equal(r.steuer[0].betragCent, 5)
})
test('Rundung halb auf an der Grenze', () => {
// brutto 107 bei 7 % => 107*7/107 = 7 exakt; brutto 50 bei 7 % => 3,271 => 3; brutto 1 bei 19 % => 0,159 => 0 (kein Eintrag)
const fueller = produkt('fueller', { preisCent: 100, steuersatz: 0 }) // Gesamtbetrag mindestens 0,50 EUR, selbst ohne Steuer
const abh = (p, n = 1) => berechne({ produkte: karte(p, fueller), positionen: [...eine(p, n), ...eine(fueller)], lieferart: 'abholung', einstellungen: einstellungen() })
assert.equal(abh(produkt('a', { preisCent: 107, steuersatz: 7 })).steuer[0].betragCent, 7)
assert.equal(abh(produkt('a', { preisCent: 50, steuersatz: 7 })).steuer[0].betragCent, 3)
assert.deepEqual(abh(produkt('a', { preisCent: 1, steuersatz: 19 })).steuer, [])
assert.equal(abh(produkt('a', { preisCent: 1, steuersatz: 19 })).texte.steuerHinweis, '')
// brutto 5 bei 19 % => 0,798 => 1
assert.equal(abh(produkt('a', { preisCent: 5, steuersatz: 19 })).steuer[0].betragCent, 1)
// brutto 30 bei 7 % => 1,962 => 2
assert.equal(abh(produkt('a', { preisCent: 30, steuersatz: 7 })).steuer[0].betragCent, 2)
})
test('Versand wird anteilig nach Bruttowert auf die Saetze verteilt, Rest-Cent auf den hoechsten Satz', () => {
const a = produkt('a', { preisCent: 1000, steuersatz: 19 })
const b = produkt('b', { preisCent: 1000, steuersatz: 7 })
const e = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 501 }, freiAbCent: null } })
const r = berechne({ produkte: karte(b, a), positionen: [...eine(b), ...eine(a)], lieferart: 'versand', land: 'DE', einstellungen: e })
// Anteile: floor(501*1000/2000) = 250 je Satz, Rest 1 => 19 %: 1251, 7 %: 1250
assert.equal(r.gesamtCent, 2501)
assert.deepEqual(r.steuer.map((s) => [s.satz, s.betragCent]), [
[19, Math.floor((2 * 1251 * 19 + 119) / (2 * 119))],
[7, Math.floor((2 * 1250 * 7 + 107) / (2 * 107))],
])
assert.equal(r.steuer[0].betragCent, 200)
assert.equal(r.steuer[1].betragCent, 82)
})
test('Versand bei nur 0-%-Artikeln erzeugt keine Steuer', () => {
const a = produkt('a', { preisCent: 1000, steuersatz: 0 })
const r = rechne({ produkte: karte(a), positionen: eine(a) })
assert.deepEqual(r.steuer, [])
assert.equal(r.gesamtCent, 1490)
})
test('Kleinunternehmer: keine Steuer, Hinweis nach Paragraph 19', () => {
const a = produkt('a', { preisCent: 1000, steuersatz: 0 })
const r = rechne({ produkte: karte(a), positionen: eine(a), einstellungen: einstellungen({ kleinunternehmer: true }) })
assert.deepEqual(r.steuer, [])
assert.equal(r.texte.steuerHinweis, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
// auch wenn ein Artikel (fehlerhaft) einen Satz traegt
const b = produkt('b', { preisCent: 1000, steuersatz: 19 })
const r2 = rechne({ produkte: karte(b), positionen: eine(b), einstellungen: einstellungen({ kleinunternehmer: true }) })
assert.deepEqual(r2.steuer, [])
})
test('Steuerart noch nicht gewählt (null) rechnet wie Regelbesteuerung, nie wie Kleinunternehmer', () => {
const a = produkt('a', { preisCent: 1190, steuersatz: 19 })
const r = rechne({ produkte: karte(a), positionen: eine(a), einstellungen: einstellungen({ kleinunternehmer: null }) })
assert.ok(r.steuer.length > 0)
assert.doesNotMatch(r.texte.steuerHinweis, /§ 19/)
})
test('Versandkosten: Oesterreich, Schwelle gleich ist kostenlos, knapp darunter nicht', () => {
const e = einstellungen({ versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 } })
const p = (cent) => produkt('a', { preisCent: cent })
const lauf = (cent, land) => {
const a = p(cent)
return berechne({ produkte: karte(a), positionen: eine(a), lieferart: 'versand', land, einstellungen: e })
}
assert.equal(lauf(2000, 'AT').versandCent, 990)
assert.equal(lauf(2000, 'AT').texte.versand, euro(990))
assert.equal(lauf(4999, 'DE').versandCent, 490)
assert.equal(lauf(5000, 'DE').versandCent, 0)
assert.equal(lauf(5000, 'AT').versandCent, 0)
assert.equal(lauf(5001, 'DE').texte.versand, 'kostenlos')
assert.equal(lauf(5000, 'DE').gesamtCent, 5000)
})
test('freiAbCent 0 heisst immer kostenlos', () => {
const a = produkt('a')
const e = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: 0 } })
assert.equal(berechne({ produkte: karte(a), positionen: eine(a), lieferart: 'versand', land: 'DE', einstellungen: e }).versandCent, 0)
})
test('Abholung: keine Versandkosten, eigener Text, Land ignoriert', () => {
const a = produkt('a', { preisCent: 1190 })
const r = berechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: einstellungen() })
assert.equal(r.ok, true)
assert.equal(r.versandCent, 0)
assert.equal(r.gesamtCent, 1190)
assert.equal(r.texte.versand, 'Abholung – kostenlos')
assert.equal(r.lieferart, 'abholung')
})
test('Abholung nur-Abholung-Artikel sind bei Abholung erlaubt', () => {
const a = produkt('a', { lieferart: 'abholung' })
const r = berechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: einstellungen() })
assert.equal(r.ok, true)
})
test('Varianten: Preis und Text der Variante', () => {
const r = rechne({ produkte: karte(shirt), positionen: eine(shirt, 2, 'sizebbbb') })
assert.equal(r.ok, true)
assert.equal(r.positionen[0].varianteId, 'sizebbbb')
assert.equal(r.positionen[0].varianteText, 'Größe: L')
assert.equal(r.positionen[0].einzelCent, 2200)
assert.equal(r.positionen[0].summeCent, 4400)
})
test('Fehler leer: leere, fehlende oder keine Liste', () => {
const a = produkt('a')
for (const positionen of [[], undefined, null, 'x', {}]) {
const r = rechne({ produkte: karte(a), positionen })
assert.equal(r.ok, false)
assert.equal(r.fehler.code, 'leer')
assert.match(r.fehler.text, /Warenkorb/)
}
})
test('Fehler zu_viele: 51 verschiedene Positionen, aber 50 sind erlaubt und Duplikate zaehlen einmal', () => {
const liste = Array.from({ length: 51 }, (_, i) => produkt(`p${i}`))
const positionen = (n) => liste.slice(0, n).map((p) => ({ produktId: p.id, menge: 1 }))
const r51 = rechne({ produkte: karte(...liste), positionen: positionen(51) })
assert.equal(r51.ok, false)
assert.equal(r51.fehler.code, 'zu_viele')
assert.equal(rechne({ produkte: karte(...liste), positionen: positionen(50) }).ok, true)
// 51 Eintraege, aber nur 50 verschiedene
const dup = [...positionen(50), { produktId: 'p0', menge: 1 }]
const r = rechne({ produkte: karte(...liste), positionen: dup })
assert.equal(r.ok, true)
assert.equal(r.positionen.length, 50)
assert.equal(r.positionen[0].menge, 2)
})
test('Fehler menge: 0, 100, negativ, Dezimal, Text, NaN, fehlend', () => {
const a = produkt('a')
for (const menge of [0, 100, -1, 1.5, '2', NaN, Infinity, undefined, null]) {
const r = rechne({ produkte: karte(a), positionen: [{ produktId: 'a', menge }] })
assert.equal(r.ok, false, String(menge))
assert.equal(r.fehler.code, 'menge', String(menge))
assert.equal(r.fehler.produktId, 'a')
}
assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 99) }).ok, true)
assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 1) }).ok, true)
})
test('Zusammengefasste Menge ueber 99 ist ein Mengenfehler', () => {
const a = produkt('a')
const r = rechne({ produkte: karte(a), positionen: [{ produktId: 'a', menge: 60 }, { produktId: 'a', menge: 40 }] })
assert.equal(r.ok, false)
assert.equal(r.fehler.code, 'menge')
assert.equal(r.fehler.produktId, 'a')
})
test('Duplikate gleicher Artikel werden zusammengefasst, Varianten bleiben getrennt', () => {
const a = produkt('a', { preisCent: 300 })
const r = rechne({
produkte: karte(a, shirt),
positionen: [
{ produktId: 'a', menge: 1 },
{ produktId: 'shirt', varianteId: 'sizeaaaa', menge: 1 },
{ produktId: 'a', menge: 2 },
{ produktId: 'shirt', varianteId: 'sizebbbb', menge: 1 },
{ produktId: 'shirt', varianteId: 'sizeaaaa', menge: 1 },
],
})
assert.equal(r.ok, true)
assert.deepEqual(r.positionen.map((p) => [p.produktId, p.varianteId, p.menge]), [
['a', null, 3], ['shirt', 'sizeaaaa', 2], ['shirt', 'sizebbbb', 1],
])
assert.equal(r.zwischensummeCent, 900 + 4000 + 2200)
})
test('Fehler nicht_verfuegbar: unbekannt, inaktiv, ungueltige Position', () => {
const a = produkt('a')
const aus = produkt('aus', { aktiv: false })
for (const pos of [{ produktId: 'x', menge: 1 }, { produktId: 'aus', menge: 1 }, { produktId: '__proto__', menge: 1 }, { produktId: 'constructor', menge: 1 }]) {
const r = rechne({ produkte: karte(a, aus), positionen: [pos] })
assert.equal(r.ok, false)
assert.equal(r.fehler.code, 'nicht_verfuegbar')
}
assert.equal(rechne({ produkte: karte(a), positionen: [null] }).fehler.code, 'nicht_verfuegbar')
assert.equal(rechne({ produkte: karte(a), positionen: [{ menge: 1 }] }).fehler.code, 'nicht_verfuegbar')
assert.equal(rechne({ produkte: karte(a), positionen: [{ produktId: 5, menge: 1 }] }).fehler.code, 'nicht_verfuegbar')
assert.equal(rechne({ produkte: karte(aus), positionen: eine(aus) }).fehler.produktId, 'aus')
})
test('Fehler variante: fehlt, unbekannt, bei Artikel ohne Varianten', () => {
const a = produkt('a')
const ohne = rechne({ produkte: karte(shirt), positionen: eine(shirt) })
assert.equal(ohne.fehler.code, 'variante')
assert.match(ohne.fehler.text, /Größe/)
assert.equal(rechne({ produkte: karte(shirt), positionen: eine(shirt, 1, 'gibtsnich') }).fehler.code, 'variante')
assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 1, 'sizeaaaa') }).fehler.code, 'variante')
assert.equal(ohne.fehler.produktId, 'shirt')
assert.equal(rechne({ produkte: karte(shirt), positionen: eine(shirt, 1, 'sizeaaaa') }).ok, true)
})
test('Bestand: unbegrenzt, genau ausreichend, zu wenig mit Restmenge, ausverkauft', () => {
const frei = produkt('frei', { bestand: null })
assert.equal(rechne({ produkte: karte(frei), positionen: eine(frei, 99) }).ok, true)
const a = produkt('a', { bestand: 3 })
assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 3) }).ok, true)
const zuwenig = rechne({ produkte: karte(a), positionen: eine(a, 4) })
assert.equal(zuwenig.ok, false)
assert.equal(zuwenig.fehler.code, 'ausverkauft')
assert.equal(zuwenig.fehler.produktId, 'a')
assert.match(zuwenig.fehler.text, /Artikel a/)
assert.match(zuwenig.fehler.text, /nur noch 3/)
const null0 = produkt('n', { bestand: 0 })
const aus = rechne({ produkte: karte(null0), positionen: eine(null0) })
assert.equal(aus.fehler.code, 'ausverkauft')
assert.match(aus.fehler.text, /Artikel n/)
assert.match(aus.fehler.text, /ausverkauft/)
})
test('Bestand: gilt fuer die zusammengefasste Menge', () => {
const a = produkt('a', { bestand: 3 })
const r = rechne({ produkte: karte(a), positionen: [{ produktId: 'a', menge: 2 }, { produktId: 'a', menge: 2 }] })
assert.equal(r.fehler.code, 'ausverkauft')
})
test('Reservierungen verringern den Bestand (Artikel und Variante)', () => {
const a = produkt('a', { bestand: 5 })
const res = new Map([['a', 3]])
assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 2), reserviert: res }).ok, true)
const r = rechne({ produkte: karte(a), positionen: eine(a, 3), reserviert: res })
assert.equal(r.fehler.code, 'ausverkauft')
assert.match(r.fehler.text, /nur noch 2/)
assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 1), reserviert: new Map([['a', 7]]) }).fehler.code, 'ausverkauft')
const vres = new Map([['shirt:sizeaaaa', 4]])
assert.equal(rechne({ produkte: karte(shirt), positionen: eine(shirt, 1, 'sizeaaaa'), reserviert: vres }).ok, true)
const rv = rechne({ produkte: karte(shirt), positionen: eine(shirt, 2, 'sizeaaaa'), reserviert: vres })
assert.equal(rv.fehler.code, 'ausverkauft')
assert.match(rv.fehler.text, /T-Shirt/)
assert.match(rv.fehler.text, /Größe: M/)
// unbegrenzte Variante bleibt unberuehrt, andere Schluessel stoeren nicht
assert.equal(rechne({ produkte: karte(shirt), positionen: eine(shirt, 50, 'sizebbbb'), reserviert: new Map([['shirt:sizebbbb', 99], ['shirt', 99]]) }).ok, true)
})
test('Variantenbestand wird je Variante geprueft', () => {
const r = rechne({ produkte: karte(shirt), positionen: eine(shirt, 6, 'sizeaaaa') })
assert.equal(r.fehler.code, 'ausverkauft')
assert.match(r.fehler.text, /nur noch 5/)
})
test('Fehler lieferart: unbekannt, Versand aus, Abholung aus', () => {
const a = produkt('a')
assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'post' }).fehler.code, 'lieferart')
assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: undefined }).fehler.code, 'lieferart')
const keinVersand = einstellungen({ versand: { aktiv: false, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null } })
assert.equal(rechne({ produkte: karte(a), positionen: eine(a), einstellungen: keinVersand }).fehler.code, 'lieferart')
const keineAbholung = einstellungen({ abholung: { aktiv: false, adresse: '', hinweis: '' } })
assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: keineAbholung }).fehler.code, 'lieferart')
// Abholung geht, obwohl Versand aus ist
assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: einstellungen({ versand: { aktiv: false, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null } }) }).ok, true)
})
test('Fehler land: nicht geliefert, fehlend, ungueltig', () => {
const a = produkt('a')
const nurDe = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null } })
for (const land of ['AT', 'CH', '', undefined, null, 'de']) {
const r = rechne({ produkte: karte(a), positionen: eine(a), land, einstellungen: nurDe })
assert.equal(r.fehler.code, 'land', String(land))
}
assert.equal(rechne({ produkte: karte(a), positionen: eine(a), land: 'AT' }).ok, true)
})
test('Fehler nur_abholung: Versand mit Nur-Abholung-Artikel', () => {
const a = produkt('a')
const abholer = produkt('abholer', { lieferart: 'abholung' })
const r = rechne({ produkte: karte(a, abholer), positionen: [...eine(a), ...eine(abholer)] })
assert.equal(r.ok, false)
assert.equal(r.fehler.code, 'nur_abholung')
assert.equal(r.fehler.produktId, 'abholer')
assert.match(r.fehler.text, /Artikel abholer/)
})
test('Fehler mindestbestellwert: Grenze gleich ist erlaubt, Versandkosten zaehlen nicht mit', () => {
const a = produkt('a', { preisCent: 1000 })
const e = (cent) => einstellungen({ mindestbestellCent: cent })
const r = rechne({ produkte: karte(a), positionen: eine(a), einstellungen: e(1001) })
assert.equal(r.ok, false)
assert.equal(r.fehler.code, 'mindestbestellwert')
assert.ok(r.fehler.text.includes(euro(1001)))
assert.equal(rechne({ produkte: karte(a), positionen: eine(a), einstellungen: e(1000) }).ok, true)
assert.equal(rechne({ produkte: karte(a), positionen: eine(a), einstellungen: e(null) }).ok, true)
// gilt auch bei Abholung
assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: e(1001) }).fehler.code, 'mindestbestellwert')
})
test('Fehler mindestbetrag: Gesamtbetrag unter 0,50 EUR (Stripe-Minimum) wird abgelehnt, 0,50 EUR genau ist erlaubt; Versandkosten zaehlen mit', () => {
const klein = produkt('klein', { preisCent: 30 })
const r = rechne({ produkte: karte(klein), positionen: eine(klein), lieferart: 'abholung' })
assert.equal(r.ok, false)
assert.equal(r.fehler.code, 'mindestbetrag')
assert.equal(r.fehler.text, 'Der Bestellwert muss mindestens 0,50 € betragen.')
const genau = produkt('genau', { preisCent: 50 })
assert.equal(rechne({ produkte: karte(genau), positionen: eine(genau), lieferart: 'abholung' }).ok, true)
const knapp = produkt('knapp', { preisCent: 49 })
assert.equal(rechne({ produkte: karte(knapp), positionen: eine(knapp), lieferart: 'abholung' }).fehler.code, 'mindestbetrag')
// mit Versandkosten ueber der Grenze
const e = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null } })
assert.equal(rechne({ produkte: karte(klein), positionen: eine(klein), lieferart: 'versand', land: 'DE', einstellungen: e }).ok, true)
})
test('Reihenfolge der Pruefungen: Artikelfehler vor Lieferart, Lieferart vor Mindestbestellwert', () => {
const a = produkt('a', { bestand: 0 })
assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'post' }).fehler.code, 'ausverkauft')
const b = produkt('b')
assert.equal(rechne({ produkte: karte(b), positionen: eine(b), lieferart: 'post', einstellungen: einstellungen({ mindestbestellCent: 99999 }) }).fehler.code, 'lieferart')
})
test('Eingaben werden nicht veraendert und das Ergebnis teilt keine Referenzen', () => {
const a = produkt('a', { bestand: 10 })
const positionen = [{ produktId: 'a', menge: 2 }, { produktId: 'a', menge: 1 }]
const e = einstellungen()
const vorher = JSON.stringify({ a, positionen, e })
const r1 = rechne({ produkte: karte(a), positionen, einstellungen: e })
assert.equal(JSON.stringify({ a, positionen, e }), vorher)
const r2 = rechne({ produkte: karte(a), positionen, einstellungen: e })
assert.deepEqual(r1, r2)
assert.notEqual(r1.positionen, r2.positionen)
})
test('Gesamtsumme ist immer ganzzahlig und gleich Zwischensumme plus Versand', () => {
const a = produkt('a', { preisCent: 333, steuersatz: 7 })
const b = produkt('b', { preisCent: 777, steuersatz: 19 })
const r = rechne({ produkte: karte(a, b), positionen: [{ produktId: 'a', menge: 7 }, { produktId: 'b', menge: 3 }], land: 'AT' })
assert.ok(Number.isInteger(r.gesamtCent))
assert.equal(r.gesamtCent, r.zwischensummeCent + r.versandCent)
assert.equal(r.zwischensummeCent, 333 * 7 + 777 * 3)
const summe = r.steuer.reduce((s, x) => s + x.betragCent, 0)
assert.ok(summe < r.gesamtCent)
})
+469
View File
@@ -0,0 +1,469 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Artikel des Shops: reines Normalisieren, Kaeufer-Sicht und Speichern gegen den Appwrite-Nachbau.
const aw = await startAppwriteMock({ schema: { shopProducts: schemaKeys('shopProducts') }, datetimes: SHOP_DATETIMES })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const P = await import('../server/services/shop/produkte.js')
const { normalisiereProdukt, produktOeffentlich, listeProdukte, holeProdukt, speichereProdukt, loescheProdukt, sortiere, bildIdsInGebrauch } = P
const klein = { kleinunternehmer: true }
const regel = { kleinunternehmer: false }
const basis = (over = {}) => ({ name: 'Lavendelseife', preisCent: 450, ...over })
const ok = (eingabe, einstellungen = regel) => {
const r = normalisiereProdukt(eingabe, { einstellungen })
assert.equal(r.fehler, undefined, JSON.stringify(r.fehler))
return r.produkt
}
const fehler = (eingabe, einstellungen = regel) => {
const r = normalisiereProdukt(eingabe, { einstellungen })
assert.equal(r.produkt, undefined)
return r.fehler
}
test('Minimaler Artikel bekommt die Standardwerte', () => {
const p = ok(basis())
assert.deepEqual(p, {
name: 'Lavendelseife',
kategorie: '',
kurz: '',
beschreibung: '',
preisCent: 450,
steuersatz: 19,
grundpreis: null,
bestand: null,
aktiv: true,
varianten: null,
bilder: [],
lieferart: 'beides',
widerruf: 'gesetzlich',
sicherheit: { hersteller: 'selbst', euVerantwortlicher: null, hinweise: '' },
lebensmittel: { aktiv: false, pflichtangaben: '' },
})
})
test('Name, Preis und Laengen werden geprueft', () => {
assert.ok(fehler({ preisCent: 100 }).name)
assert.ok(fehler(basis({ name: ' ' })).name)
assert.ok(fehler(basis({ name: 'x'.repeat(121) })).name)
assert.ok(fehler(basis({ kategorie: 'x'.repeat(41) })).kategorie)
assert.ok(fehler(basis({ kurz: 'x'.repeat(301) })).kurz)
assert.ok(fehler(basis({ beschreibung: 'x'.repeat(5001) })).beschreibung)
for (const preis of [0, -5, 10_000_001, 4.5, '4,50', null, undefined]) assert.ok(fehler(basis({ preisCent: preis })).preisCent, String(preis))
assert.equal(ok(basis({ preisCent: 10_000_000 })).preisCent, 10_000_000)
assert.ok(fehler('kaputt').produkt)
assert.ok(fehler(null).produkt)
assert.ok(fehler([]).produkt)
})
test('Texte: getrimmt, Steuerzeichen entfernt, Umbrueche nur in Langtexten', () => {
const p = ok(basis({ name: ' Seife\u0000​ mit\nUmbruch ', kurz: 'a\nb', beschreibung: 'Zeile 1\r\nZeile\u0007 2', sicherheit: { hinweise: 'Nicht\nessen\u0000' } }))
assert.equal(p.name, 'Seife mit Umbruch')
assert.equal(p.kurz, 'a b')
assert.equal(p.beschreibung, 'Zeile 1\nZeile 2')
assert.equal(p.sicherheit.hinweise, 'Nicht\nessen')
})
test('Steuersatz: 0, 7, 19; Kleinunternehmer erzwingt 0', () => {
assert.equal(ok(basis({ steuersatz: 7 })).steuersatz, 7)
assert.equal(ok(basis({ steuersatz: 0 })).steuersatz, 0)
assert.equal(ok(basis({ steuersatz: 19 })).steuersatz, 19)
assert.ok(fehler(basis({ steuersatz: 16 })).steuersatz)
assert.ok(fehler(basis({ steuersatz: 'viel' })).steuersatz)
assert.equal(ok(basis({ steuersatz: 19 }), klein).steuersatz, 0)
assert.equal(ok(basis({ steuersatz: 7 }), klein).steuersatz, 0)
assert.equal(ok(basis(), klein).steuersatz, 0)
assert.equal(ok(basis({ steuersatz: 19 }), { kleinunternehmer: null }).steuersatz, 19, 'Steuerart nicht gewählt: kein Kleinunternehmer')
})
test('Bestand: ganze Zahl ab 0 oder unbegrenzt', () => {
assert.equal(ok(basis({ bestand: 0 })).bestand, 0)
assert.equal(ok(basis({ bestand: 12 })).bestand, 12)
assert.equal(ok(basis({ bestand: '7' })).bestand, 7)
assert.equal(ok(basis({ bestand: null })).bestand, null)
assert.equal(ok(basis({ bestand: '' })).bestand, null)
for (const b of [-1, 1.5, 'viele']) assert.ok(fehler(basis({ bestand: b })).bestand, String(b))
})
test('Grundpreis: Menge und Einheit', () => {
assert.deepEqual(ok(basis({ grundpreis: { menge: 100, einheit: 'g' } })).grundpreis, { menge: 100, einheit: 'g' })
assert.deepEqual(ok(basis({ grundpreis: { menge: '0,75', einheit: 'l' } })).grundpreis, { menge: 0.75, einheit: 'l' })
assert.equal(ok(basis({ grundpreis: null })).grundpreis, null)
assert.equal(ok(basis({ grundpreis: { menge: '', einheit: '' } })).grundpreis, null)
assert.ok(fehler(basis({ grundpreis: { menge: 0, einheit: 'g' } })).grundpreis?.menge ?? fehler(basis({ grundpreis: { menge: 0, einheit: 'g' } }))['grundpreis.menge'])
assert.ok(fehler(basis({ grundpreis: { menge: 100, einheit: 'stueck' } }))['grundpreis.einheit'])
assert.ok(fehler(basis({ grundpreis: { menge: 'viel', einheit: 'g' } }))['grundpreis.menge'])
assert.ok(fehler(basis({ grundpreis: 'x' })).grundpreis)
})
test('aktiv, Lieferart, Widerruf', () => {
assert.equal(ok(basis({ aktiv: false })).aktiv, false)
assert.ok(fehler(basis({ aktiv: 'ja' })).aktiv)
assert.equal(ok(basis({ lieferart: 'abholung' })).lieferart, 'abholung')
assert.ok(fehler(basis({ lieferart: 'drohne' })).lieferart)
for (const w of ['gesetzlich', 'verderblich', 'individuell', 'hygiene']) assert.equal(ok(basis({ widerruf: w })).widerruf, w)
assert.ok(fehler(basis({ widerruf: 'nie' })).widerruf)
})
test('Varianten: Preis und Bestand nur an den Werten, IDs vom Server', () => {
const p = ok(
basis({
preisCent: 999,
bestand: 5,
varianten: {
name: 'Duft',
werte: [
{ name: 'Lavendel', preisCent: 350, bestand: 3 },
{ id: 'abcd1234', name: 'Rose', preisCent: 450 },
{ id: 'ZU-KURZ', name: 'Minze', preisCent: 400, bestand: '0' },
{ id: 'abcd1234', name: 'Zitrone', preisCent: 400 },
],
},
}),
)
assert.equal(p.preisCent, null)
assert.equal(p.bestand, null)
assert.equal(p.varianten.name, 'Duft')
const ids = p.varianten.werte.map((w) => w.id)
assert.equal(ids[1], 'abcd1234')
for (const id of ids) assert.match(id, /^[a-z0-9]{8}$/)
assert.equal(new Set(ids).size, 4)
assert.deepEqual(p.varianten.werte.map((w) => w.bestand), [3, null, 0, null])
assert.deepEqual(p.varianten.werte.map((w) => w.preisCent), [350, 450, 400, 400])
})
test('Varianten: Fehler je Wert', () => {
const f = fehler(basis({ varianten: { name: '', werte: [{ name: 'A', preisCent: 0 }, { name: '', preisCent: 100 }, { name: 'a', preisCent: 100 }] } }))
assert.ok(f['varianten.name'])
assert.ok(f['varianten.werte.0.preisCent'])
assert.ok(f['varianten.werte.1.name'])
assert.ok(f['varianten.werte.2.name'])
assert.ok(fehler(basis({ varianten: { name: 'Größe', werte: [] } }))['varianten.werte'])
const zuViele = Array.from({ length: config.shop.maxVarianten + 1 }, (_, i) => ({ name: `W${i}`, preisCent: 100 }))
assert.ok(fehler(basis({ varianten: { name: 'Größe', werte: zuViele } }))['varianten.werte'])
const genau = zuViele.slice(0, config.shop.maxVarianten)
assert.equal(ok(basis({ varianten: { name: 'Größe', werte: genau } })).varianten.werte.length, config.shop.maxVarianten)
// leere Variante = keine Varianten, dann zaehlt der Artikelpreis
assert.equal(ok(basis({ varianten: { name: '', werte: [] } })).varianten, null)
assert.equal(ok(basis({ varianten: null })).varianten, null)
})
test('Bilder: Bild-IDs, hoechstens maxBilder, eindeutig', () => {
assert.deepEqual(ok(basis({ bilder: ['abc-123', 'def'] })).bilder, ['abc-123', 'def'])
assert.ok(fehler(basis({ bilder: ['a', 'b', 'c', 'd', 'e'] })).bilder)
assert.equal(ok(basis({ bilder: ['a', 'b', 'c', 'd'] })).bilder.length, config.shop.maxBilder)
assert.ok(fehler(basis({ bilder: ['a', 'a'] })).bilder)
assert.ok(fehler(basis({ bilder: ['../x'] })).bilder)
assert.ok(fehler(basis({ bilder: ['A'] })).bilder)
assert.ok(fehler(basis({ bilder: [''] })).bilder)
assert.ok(fehler(basis({ bilder: [1] })).bilder)
assert.ok(fehler(basis({ bilder: 'abc' })).bilder)
})
test('Lebensmittel braucht Pflichtangaben', () => {
const f = fehler(basis({ lebensmittel: { aktiv: true, pflichtangaben: ' ' } }))
assert.ok(f['lebensmittel.pflichtangaben'])
assert.deepEqual(ok(basis({ lebensmittel: { aktiv: true, pflichtangaben: 'Zutaten: Mehl. Allergene: Gluten.' } })).lebensmittel, { aktiv: true, pflichtangaben: 'Zutaten: Mehl. Allergene: Gluten.' })
assert.ok(fehler(basis({ lebensmittel: { aktiv: true, pflichtangaben: 'x'.repeat(4001) } }))['lebensmittel.pflichtangaben'])
assert.equal(ok(basis({ lebensmittel: { aktiv: false } })).lebensmittel.aktiv, false)
})
test('Produktsicherheit: Hersteller selbst oder vollstaendig, EU-Verantwortlicher optional aber vollstaendig', () => {
const herst = { name: 'Seifen GmbH', anschrift: 'Weg 1, 12345 Ort', kontakt: 'info@seifen.example' }
assert.equal(ok(basis({ sicherheit: { hersteller: 'selbst' } })).sicherheit.hersteller, 'selbst')
assert.deepEqual(ok(basis({ sicherheit: { hersteller: herst } })).sicherheit.hersteller, herst)
const f = fehler(basis({ sicherheit: { hersteller: { name: 'X' } } }))
assert.ok(f['sicherheit.hersteller.anschrift'])
assert.ok(f['sicherheit.hersteller.kontakt'])
assert.ok(fehler(basis({ sicherheit: { hersteller: 'jemand' } }))['sicherheit.hersteller'])
assert.ok(fehler(basis({ sicherheit: { hersteller: 42 } }))['sicherheit.hersteller'])
assert.deepEqual(ok(basis({ sicherheit: { hersteller: herst, euVerantwortlicher: herst } })).sicherheit.euVerantwortlicher, herst)
assert.equal(ok(basis({ sicherheit: { hersteller: herst, euVerantwortlicher: { name: '', anschrift: '', kontakt: '' } } })).sicherheit.euVerantwortlicher, null)
assert.ok(fehler(basis({ sicherheit: { hersteller: herst, euVerantwortlicher: { name: 'EU' } } }))['sicherheit.euVerantwortlicher.anschrift'])
assert.ok(fehler(basis({ sicherheit: { hinweise: 'x'.repeat(2001) } }))['sicherheit.hinweise'])
})
test('Normalisieren teilt keine Referenzen und ignoriert unbekannte Felder und __proto__', () => {
const eingabe = JSON.parse('{"name":"A","preisCent":100,"bilder":["a"],"__proto__":{"preisCent":1},"boese":1}')
const p = ok(eingabe)
p.bilder.push('x')
assert.deepEqual(eingabe.bilder, ['a'])
assert.equal(p.boese, undefined)
assert.equal(p.preisCent, 100)
})
// ---- Kaeufer-Sicht -------------------------------------------------------------------------------------------------
const doc = (produkt, id = 'p1') => ({ $id: id, produkt: JSON.stringify(produkt) })
const oeff = (eingabe, extra = {}, einstellungen = regel) => {
const d = doc(ok(eingabe, einstellungen), extra.id)
return produktOeffentlich(d, { einstellungen, siteKey: 'abc123', ...extra })
}
test('produktOeffentlich: Einzelartikel', () => {
const o = oeff(basis({ kurz: 'Handgemacht', grundpreis: { menge: 100, einheit: 'g' }, bilder: ['bild-1', 'bild2'], steuersatz: 7, bestand: 3, kategorie: 'Seife' }))
assert.equal(o.id, 'p1')
assert.equal(o.name, 'Lavendelseife')
assert.equal(o.kategorie, 'Seife')
assert.equal(o.kurz, 'Handgemacht')
assert.equal(o.preisText, '4,50\u00a0€')
assert.equal(o.preisAbText, undefined)
assert.equal(o.grundpreisText, '(45,00\u00a0€ / 1 kg)')
assert.equal(o.steuersatz, 7)
assert.equal(o.varianten, null)
assert.deepEqual(o.bilder, [
{ klein: '/api/public/shop/v1/abc123/bild/bild-1-s.webp', gross: '/api/public/shop/v1/abc123/bild/bild-1-l.webp' },
{ klein: '/api/public/shop/v1/abc123/bild/bild2-s.webp', gross: '/api/public/shop/v1/abc123/bild/bild2-l.webp' },
])
assert.equal(o.lieferart, 'beides')
assert.equal(o.lieferHinweis, '')
assert.equal(o.widerruf, 'gesetzlich')
assert.equal(o.widerrufText, '')
assert.deepEqual(o.sicherheit, { hersteller: null, euVerantwortlicher: null, hinweise: '' })
assert.equal(o.lebensmittel, null)
assert.equal(o.verfuegbar, true)
assert.equal(o.ausverkauft, false)
assert.equal('bestand' in o, false)
assert.equal('bestandText' in o, false)
assert.equal('preisCent' in o, false)
})
test('produktOeffentlich: Lieferart, Widerrufstexte, Hersteller, Lebensmittel', () => {
assert.equal(oeff(basis({ lieferart: 'abholung' })).lieferHinweis, 'Nur Abholung')
assert.equal(oeff(basis({ widerruf: 'verderblich' })).widerrufText, 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware')
assert.match(oeff(basis({ widerruf: 'individuell' })).widerrufText, /nach Kundenwunsch angefertigt/)
assert.match(oeff(basis({ widerruf: 'hygiene' })).widerrufText, /versiegelte Ware aus Hygienegründen, wenn die Versiegelung entfernt wurde/)
const herst = { name: 'Seifen GmbH', anschrift: 'Weg 1', kontakt: 'a@b.de' }
const o = oeff(basis({ sicherheit: { hersteller: herst, hinweise: 'Nicht essen' }, lebensmittel: { aktiv: true, pflichtangaben: 'Zutaten: Salz' } }))
assert.deepEqual(o.sicherheit.hersteller, herst)
assert.equal(o.sicherheit.hinweise, 'Nicht essen')
assert.deepEqual(o.lebensmittel, { pflichtangaben: 'Zutaten: Salz' })
})
test('produktOeffentlich: Verfuegbarkeit mit Bestand und Reservierung', () => {
const eingabe = basis({ bestand: 2 })
assert.equal(oeff(eingabe).verfuegbar, true)
assert.equal(oeff(eingabe, { reserviert: new Map([['p1', 1]]) }).verfuegbar, true)
const aus = oeff(eingabe, { reserviert: new Map([['p1', 2]]) })
assert.equal(aus.verfuegbar, false)
assert.equal(aus.ausverkauft, true)
assert.equal(oeff(basis({ bestand: 0 })).ausverkauft, true)
assert.equal(oeff(basis({ bestand: null }), { reserviert: new Map([['p1', 99]]) }).verfuegbar, true)
assert.equal(oeff(eingabe, { reserviert: new Map([['p1', 5]]) }).verfuegbar, false)
assert.equal(oeff(eingabe, { reserviert: undefined }).verfuegbar, true)
})
test('produktOeffentlich: Varianten mit "ab"-Preis und Verfuegbarkeit je Wert', () => {
const eingabe = basis({
varianten: {
name: 'Duft',
werte: [
{ id: 'aaaaaaaa', name: 'Lavendel', preisCent: 350, bestand: 1 },
{ id: 'bbbbbbbb', name: 'Rose', preisCent: 450 },
],
},
})
const o = oeff(eingabe, { reserviert: new Map([['p1:aaaaaaaa', 1], ['p1', 50]]) })
assert.equal(o.preisText, '3,50\u00a0€')
assert.equal(o.preisAbText, 'ab 3,50\u00a0€')
assert.equal(o.varianten.name, 'Duft')
assert.deepEqual(o.varianten.werte.map((w) => [w.id, w.name, w.preisText, w.verfuegbar]), [
['aaaaaaaa', 'Lavendel', '3,50\u00a0€', false],
['bbbbbbbb', 'Rose', '4,50\u00a0€', true],
])
assert.equal(o.verfuegbar, true)
const alle = oeff(eingabe, { reserviert: new Map([['p1:aaaaaaaa', 1]]) })
assert.equal(alle.verfuegbar, true)
const keine = oeff(basis({ varianten: { name: 'Duft', werte: [{ id: 'aaaaaaaa', name: 'L', preisCent: 350, bestand: 0 }] } }))
assert.equal(keine.ausverkauft, true)
// gleiche Preise: kein "ab"
const gleich = oeff(basis({ varianten: { name: 'Farbe', werte: [{ name: 'Rot', preisCent: 500 }, { name: 'Blau', preisCent: 500 }] }, grundpreis: { menge: 50, einheit: 'g' } }))
assert.equal(gleich.preisText, '5,00\u00a0€')
assert.equal(gleich.preisAbText, undefined)
assert.equal(gleich.grundpreisText, '(100,00\u00a0€ / 1 kg)')
})
test('produktOeffentlich: kaputter Datensatz liefert null, Fremdtext bleibt Text', () => {
assert.equal(produktOeffentlich({ $id: 'x', produkt: '{kaputt' }, { siteKey: 's' }), null)
assert.equal(produktOeffentlich({ $id: 'x', produkt: '"text"' }, { siteKey: 's' }), null)
assert.equal(produktOeffentlich({ $id: 'x' }, { siteKey: 's' }), null)
const o = oeff(basis({ name: '<img src=x onerror=alert(1)>' }))
assert.equal(o.name, '<img src=x onerror=alert(1)>')
})
// ---- Speichern -----------------------------------------------------------------------------------------------------
const SHOP = { shopId: 'shop-a', customerId: 'cust-a' }
const fremd = { shopId: 'shop-b', customerId: 'cust-b' }
test('speichern: anlegen sortiert ans Ende, Update behaelt sort, JSON in produkt', async () => {
const a = await speichereProdukt(SHOP, null, ok(basis({ name: 'A' })))
const b = await speichereProdukt(SHOP, null, ok(basis({ name: 'B' })))
const c = await speichereProdukt(SHOP, null, ok(basis({ name: 'C', aktiv: false })))
assert.deepEqual([a.sort, b.sort, c.sort], [0, 1, 2])
assert.equal(a.shopId, 'shop-a')
assert.equal(a.customerId, 'cust-a')
assert.equal(c.aktiv, false)
assert.equal(JSON.parse(a.produkt).name, 'A')
const neu = await speichereProdukt(SHOP, b.$id, ok(basis({ name: 'B2', preisCent: 999 })))
assert.equal(neu.sort, 1)
assert.equal(neu.name, 'B2')
assert.equal(JSON.parse(neu.produkt).preisCent, 999)
const liste = await listeProdukte('shop-a')
assert.deepEqual(liste.map((d) => d.name), ['A', 'B2', 'C'])
})
test('Shops sind getrennt: Liste, Holen, Aendern, Loeschen, Sortieren', async () => {
const x = await speichereProdukt(fremd, null, ok(basis({ name: 'Fremd' })))
assert.equal(x.sort, 0)
assert.deepEqual((await listeProdukte('shop-b')).map((d) => d.name), ['Fremd'])
assert.equal(await holeProdukt('shop-a', x.$id), null)
assert.equal((await holeProdukt('shop-b', x.$id)).name, 'Fremd')
assert.equal(await holeProdukt('shop-a', 'gibt-es-nicht'), null)
assert.equal(await holeProdukt('shop-a', '../etc'), null)
await assert.rejects(speichereProdukt(SHOP, x.$id, ok(basis())), (e) => e.status === 404)
await assert.rejects(loescheProdukt('shop-a', x.$id), (e) => e.status === 404)
await assert.rejects(sortiere('shop-a', [x.$id]), (e) => e.status === 400)
assert.equal((await holeProdukt('shop-b', x.$id)).name, 'Fremd')
})
test('speichern: unvollstaendiger oder zu grosser Artikel => 400', async () => {
await assert.rejects(speichereProdukt(SHOP, null, {}), (e) => e.status === 400 && /unvollständig/.test(e.message))
await assert.rejects(speichereProdukt(SHOP, null, null), (e) => e.status === 400)
const riesig = ok(basis({ sicherheit: { hinweise: 'ä'.repeat(2000) }, beschreibung: 'ä'.repeat(5000), lebensmittel: { aktiv: true, pflichtangaben: 'ä'.repeat(4000) }, varianten: { name: 'V', werte: Array.from({ length: 30 }, (_, i) => ({ name: `Wert-${i}-${'x'.repeat(30)}`, preisCent: 100 })) } }))
riesig.kurz = 'ü'.repeat(20000)
await assert.rejects(speichereProdukt(SHOP, null, riesig), (e) => e.status === 400)
})
test('sortieren: genau die Artikel des Shops, sonst 400', async () => {
const liste = await listeProdukte('shop-a')
const ids = liste.map((d) => d.$id)
const neu = [ids[2], ids[0], ids[1]]
const r = await sortiere('shop-a', neu)
assert.deepEqual(r.map((d) => [d.$id, d.sort]), neu.map((id, i) => [id, i]))
assert.deepEqual((await listeProdukte('shop-a')).map((d) => d.$id), neu)
await assert.rejects(sortiere('shop-a', ids.slice(0, 2)), (e) => e.status === 400)
await assert.rejects(sortiere('shop-a', [...ids, 'noch-einer']), (e) => e.status === 400)
await assert.rejects(sortiere('shop-a', [ids[0], ids[0], ids[1]]), (e) => e.status === 400)
await assert.rejects(sortiere('shop-a', 'abc'), (e) => e.status === 400)
await assert.rejects(sortiere('shop-a', [1, 2, 3]), (e) => e.status === 400)
assert.deepEqual((await listeProdukte('shop-a')).map((d) => d.$id), neu)
})
test('loeschen und neu anlegen: sort = max + 1', async () => {
const liste = await listeProdukte('shop-a')
assert.equal(await loescheProdukt('shop-a', liste[0].$id), true)
assert.equal(await holeProdukt('shop-a', liste[0].$id), null)
await assert.rejects(loescheProdukt('shop-a', liste[0].$id), (e) => e.status === 404)
const neu = await speichereProdukt(SHOP, null, ok(basis({ name: 'Spaeter' })))
assert.equal(neu.sort, 3)
})
test('Bild-IDs in Gebrauch', async () => {
await speichereProdukt(SHOP, null, ok(basis({ name: 'Mit Bildern', bilder: ['bild-x', 'bild-y'] })))
await speichereProdukt(fremd, null, ok(basis({ name: 'Fremd2', bilder: ['bild-fremd'] })))
const ids = await bildIdsInGebrauch('shop-a')
assert.ok(ids instanceof Set)
assert.deepEqual([...ids].sort(), ['bild-x', 'bild-y'])
assert.equal((await bildIdsInGebrauch('shop-leer')).size, 0)
})
test('Obergrenze maxArtikel => 409, Update bleibt moeglich', async () => {
const alt = config.shop.maxArtikel
const vorhanden = (await listeProdukte('shop-a')).length
config.shop.maxArtikel = vorhanden
try {
await assert.rejects(speichereProdukt(SHOP, null, ok(basis({ name: 'Zu viel' }))), (e) => e.status === 409 && /Höchstzahl/.test(e.message))
const erster = (await listeProdukte('shop-a'))[0]
await speichereProdukt(SHOP, erster.$id, ok(basis({ name: 'Noch erlaubt' })))
// anderer Shop hat eigenes Limit
config.shop.maxArtikel = (await listeProdukte('shop-b')).length + 1
await speichereProdukt(fremd, null, ok(basis({ name: 'Passt gerade' })))
await assert.rejects(speichereProdukt(fremd, null, ok(basis({ name: 'Zu viel' }))), (e) => e.status === 409)
} finally {
config.shop.maxArtikel = alt
}
})
test('parallele Anlage vergibt verschiedene sort-Werte', async () => {
const shop = { shopId: 'shop-par', customerId: 'c' }
const docs = await Promise.all(Array.from({ length: 5 }, (_, i) => speichereProdukt(shop, null, ok(basis({ name: `P${i}` })))))
assert.deepEqual(docs.map((d) => d.sort).sort(), [0, 1, 2, 3, 4])
})
// ---- Bestand beim Bearbeiten (Review A, I3): nur ein vom Haendler geaenderter Bestand wird geschrieben ------------------
const bestandVon = async (id) => JSON.parse((await holeProdukt('shop-a', id)).produkt)
async function mitGeladen(id, roh, normalisiert = ok(roh)) {
return speichereProdukt(SHOP, id, normalisiert, { geladen: P.leseBestandGeladen(roh) })
}
test('leseBestandGeladen: Zahl, null und leer werden gelesen, Unbrauchbares gilt als nicht gesendet', () => {
assert.deepEqual(P.leseBestandGeladen({ bestandGeladen: 5 }), { hat: true, bestand: 5, werte: new Map() })
assert.deepEqual(P.leseBestandGeladen({ bestandGeladen: null }), { hat: true, bestand: null, werte: new Map() })
assert.equal(P.leseBestandGeladen({ bestandGeladen: '3' }).bestand, 3)
assert.equal(P.leseBestandGeladen({ bestandGeladen: '' }).bestand, null)
assert.equal(P.leseBestandGeladen({ bestandGeladen: 'abc' }).hat, false)
assert.equal(P.leseBestandGeladen({}).hat, false)
assert.equal(P.leseBestandGeladen(null).hat, false)
const w = P.leseBestandGeladen({ varianten: { werte: [{ id: 'aaaaaaaa', bestandGeladen: 2 }, { id: 'bbbbbbbb', bestandGeladen: null }, { id: 'cccccccc' }, { bestandGeladen: 1 }] } })
assert.deepEqual([...w.werte], [['aaaaaaaa', 2], ['bbbbbbbb', null]])
})
test('speichern I3: unveraenderter Bestand ueberschreibt nicht den inzwischen verkauften Stand', async () => {
const a = await speichereProdukt(SHOP, null, ok(basis({ name: 'Kerze', bestand: 5 })))
// Es wurden 3 Stueck verkauft
await speichereProdukt(SHOP, a.$id, { ...(await bestandVon(a.$id)), bestand: 2 })
const g = await mitGeladen(a.$id, basis({ name: 'Kerze neu', bestand: 5, bestandGeladen: 5 }))
assert.equal(JSON.parse(g.produkt).name, 'Kerze neu')
assert.equal((await bestandVon(a.$id)).bestand, 2)
})
test('speichern I3: geaenderter Bestand wird geschrieben (auch auf unbegrenzt und auf 0)', async () => {
const a = await speichereProdukt(SHOP, null, ok(basis({ name: 'Kerze', bestand: 5 })))
await speichereProdukt(SHOP, a.$id, { ...(await bestandVon(a.$id)), bestand: 2 })
await mitGeladen(a.$id, basis({ name: 'Kerze', bestand: 9, bestandGeladen: 5 }))
assert.equal((await bestandVon(a.$id)).bestand, 9)
await mitGeladen(a.$id, basis({ name: 'Kerze', bestand: '', bestandGeladen: 9 }))
assert.equal((await bestandVon(a.$id)).bestand, null)
await mitGeladen(a.$id, basis({ name: 'Kerze', bestand: 0, bestandGeladen: null }))
assert.equal((await bestandVon(a.$id)).bestand, 0)
})
test('speichern I3: ohne bestandGeladen gilt der gesendete Bestand wie bisher', async () => {
const a = await speichereProdukt(SHOP, null, ok(basis({ name: 'Kerze', bestand: 5 })))
await speichereProdukt(SHOP, a.$id, ok(basis({ name: 'Kerze', bestand: 7 })))
assert.equal((await bestandVon(a.$id)).bestand, 7)
})
test('speichern I3: Variantenbestand je Wert - unveraenderte Werte behalten den aktuellen Stand, geaenderte und neue Werte nicht', async () => {
const roh = (w) => basis({ name: 'Shirt', preisCent: undefined, varianten: { name: 'Größe', werte: w } })
const a = await speichereProdukt(SHOP, null, ok(roh([{ id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 5 }, { id: 'bbbbbbbb', name: 'M', preisCent: 900, bestand: 5 }, { id: 'cccccccc', name: 'L', preisCent: 900, bestand: null }])))
// S: 2 verkauft, M: 5 verkauft
const stand = await bestandVon(a.$id)
stand.varianten.werte[0].bestand = 3
stand.varianten.werte[1].bestand = 0
await speichereProdukt(SHOP, a.$id, stand)
await mitGeladen(a.$id, roh([
{ id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 5, bestandGeladen: 5 }, // unberuehrt
{ id: 'bbbbbbbb', name: 'M', preisCent: 900, bestand: 20, bestandGeladen: 5 }, // Haendler hat aufgestockt
{ id: 'cccccccc', name: 'L', preisCent: 900, bestand: null, bestandGeladen: null }, // unbegrenzt bleibt
{ name: 'XL', preisCent: 900, bestand: 4 }, // neuer Wert
]))
const nachher = (await bestandVon(a.$id)).varianten.werte
assert.deepEqual(nachher.map((w) => [w.name, w.bestand]), [['S', 3], ['M', 20], ['L', null], ['XL', 4]])
})
test('speichern I3: Bestand beim Anlegen bleibt wie gesendet; bestandGeladen landet nie im gespeicherten Artikel', async () => {
const a = await mitGeladen(null, basis({ name: 'Neu', bestand: 4, bestandGeladen: 4 }))
assert.equal((await bestandVon(a.$id)).bestand, 4)
assert.ok(!JSON.stringify(await bestandVon(a.$id)).includes('Geladen'))
})
+396
View File
@@ -0,0 +1,396 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
musterWiderrufsbelehrung,
musterWiderrufsformular,
musterAgb,
versandUndZahlung,
gewaehrleistungsHinweis,
zahlungsartenText,
normalisiereRechtstexte,
vollstaendig,
rechtstexteHash,
BESTAETIGUNG_TEXT,
pruefeBestaetigbar,
MUSTER_KOPFZEILE,
MAX_AGB,
MAX_WIDERRUFSBELEHRUNG,
MAX_WIDERRUFSFORMULAR,
maskierteLaenge,
} from '../server/services/shop/rechtstexte.js'
import { STANDARD_EINSTELLUNGEN } from '../server/services/shop/einstellungen.js'
const NB = '\u00a0'
const basis = () => {
const e = JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN))
e.anbieter = { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '0631 123456', ustId: 'DE123456789' }
e.lieferzeit = '2–4 Werktage'
e.kleinunternehmer = false
return e
}
const WIDERRUF_URL = 'https://brot.example/shop#widerruf'
test('Widerrufsbelehrung: gesetzliche Kernsaetze und eingesetzte Anbieterdaten', () => {
const t = musterWiderrufsbelehrung(basis(), { widerrufUrl: WIDERRUF_URL })
for (const s of [
'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
'Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat.',
'die letzte Ware in Besitz genommen haben bzw. hat.',
'mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail)',
'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.',
'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.',
'unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist.',
'in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.',
'Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist.',
'Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.',
]) {
assert.ok(t.includes(s), `fehlt: ${s}`)
}
assert.ok(t.includes('(Brot & Mehr GmbH, Hauptstraße 5, 67655 Kaiserslautern, Telefon: 0631 123456, E-Mail: shop@brot.example)'))
assert.ok(t.includes('an uns (Brot & Mehr GmbH, Hauptstraße 5, 67655 Kaiserslautern) zurückzusenden oder zu übergeben'))
assert.ok(!t.includes('['), 'keine offenen Platzhalter bei vollen Angaben')
assert.ok(t.split('\n\n').length > 10)
})
test('Widerrufsbelehrung: Hinweis auf die Widerrufsfunktion mit Adresse', () => {
const t = musterWiderrufsbelehrung(basis(), { widerrufUrl: WIDERRUF_URL })
assert.ok(t.includes(`Sie können Ihr Widerrufsrecht auch online unter ${WIDERRUF_URL} ausüben.`))
assert.ok(
t.includes(
'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.'
)
)
// ohne Adresse: sichtbarer Platzhalter statt leerer Stelle
assert.ok(musterWiderrufsbelehrung(basis()).includes('[Internetadresse der Widerrufsfunktion]'))
})
test('Widerrufsbelehrung: Variante Ruecksendekosten', () => {
const e = basis()
assert.ok(musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).includes('Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.'))
e.ruecksendekosten = 'haendler'
const t = musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL })
assert.ok(t.includes('Wir tragen die Kosten der Rücksendung der Waren.'))
assert.ok(!t.includes('Sie tragen die unmittelbaren Kosten'))
})
test('Widerrufsbelehrung: Abholung nur wenn angeboten', () => {
const e = basis()
assert.ok(!musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).includes('abgeholt'))
e.abholung = { aktiv: true, adresse: 'Lager, Bahnhofstraße 1, 67655 Kaiserslautern', hinweis: '' }
assert.ok(musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).includes('Haben Sie die Waren bei uns abgeholt, übergeben Sie diese zur Rückgabe an unserer Anschrift: Lager, Bahnhofstraße 1, 67655 Kaiserslautern.'))
})
test('Widerrufsbelehrung: Ausschluss und vorzeitiges Erlöschen nach § 312g Abs. 2 Nr. 1, 2, 3', () => {
const t = musterWiderrufsbelehrung(basis(), { widerrufUrl: WIDERRUF_URL })
assert.ok(t.includes('Ausschluss bzw. vorzeitiges Erlöschen des Widerrufsrechts'))
assert.ok(t.includes('die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Verbraucher maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Verbrauchers zugeschnitten sind (§ 312g Abs. 2 Nr. 1 BGB)'))
assert.ok(t.includes('Waren, die schnell verderben können oder deren Verfallsdatum schnell überschritten würde (§ 312g Abs. 2 Nr. 2 BGB)'))
assert.ok(t.includes('Das Widerrufsrecht erlischt vorzeitig bei Verträgen zur Lieferung versiegelter Waren, die aus Gründen des Gesundheitsschutzes oder der Hygiene nicht zur Rückgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde (§ 312g Abs. 2 Nr. 3 BGB)'))
})
test('Widerrufsbelehrung: leere Anbieterdaten ergeben Platzhalter, kein Absturz', () => {
const t = musterWiderrufsbelehrung(STANDARD_EINSTELLUNGEN, { widerrufUrl: WIDERRUF_URL })
assert.ok(t.includes('[Name]'))
assert.ok(t.includes('[Anschrift]'))
assert.ok(t.includes('[E-Mail-Adresse]'))
assert.doesNotThrow(() => musterWiderrufsbelehrung(undefined))
assert.doesNotThrow(() => musterAgb({}))
assert.doesNotThrow(() => versandUndZahlung(null))
})
test('Widerrufsformular nach Anlage 2 mit Anschrift', () => {
const t = musterWiderrufsformular(basis())
assert.ok(t.includes('An Brot & Mehr GmbH, Hauptstraße 5, 67655 Kaiserslautern, E-Mail: shop@brot.example:'))
assert.ok(t.includes('Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)'))
assert.ok(t.includes('– Bestellt am (*)/erhalten am (*)'))
assert.ok(t.includes('– Name des/der Verbraucher(s)'))
assert.ok(t.includes('– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)'))
assert.ok(t.includes('(*) Unzutreffendes streichen.'))
assert.ok(t.startsWith('Muster-Widerrufsformular'))
})
test('AGB: Kopf, Paragraphen, Vertragsschluss, Zahlung, Streitbeilegung ohne OS-Plattform', () => {
const e = basis()
e.zahlungsarten = ['card', 'klarna', 'sepa_debit']
e.versand.laender = ['DE', 'AT']
e.kleinunternehmer = false
const t = musterAgb(e)
assert.ok(t.startsWith('Muster – bitte prüfen und anpassen'))
for (let i = 1; i <= 10; i += 1) assert.ok(new RegExp(`^§ ${i} `, 'm').test(t), `§ ${i} fehlt`)
assert.ok(t.includes('Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab.'))
// I-1 c: Ablauf wie im Shop - Bestätigungsseite, Karte/Klarna sofort, SEPA erst Eingangsmail und Vertrag nach Zahlungseingang
assert.ok(t.includes('Den Eingang Ihrer Bestellung bestätigen wir Ihnen sofort auf einer Bestätigungsseite im Shop.'))
assert.ok(t.includes('Bei den Zahlungsarten Kreditkarte, Apple Pay, Google Pay, Klarna erhalten Sie die Bestellbestätigung per E-Mail unmittelbar nach erfolgreicher Zahlung; mit dieser E-Mail nehmen wir Ihre Bestellung an und der Vertrag kommt zustande.'))
assert.ok(t.includes('Bei SEPA-Lastschrift senden wir Ihnen zunächst eine E-Mail über den Eingang Ihrer Bestellung; der Vertrag kommt in diesem Fall erst zustande, wenn wir Ihnen nach Eingang der Zahlung die Bestellbestätigung per E-Mail senden.'))
assert.doesNotMatch(t, /Wir bestätigen den Eingang der Bestellung unverzüglich per E-Mail/)
// Art. 246c EGBGB: technische Schritte, Eingabefehler, Speicherung, Sprache, Verhaltenskodizes
assert.ok(t.includes('Zum Vertragsschluss führen folgende Schritte:'))
assert.ok(t.includes('Eingabefehler erkennen und berichtigen'))
assert.ok(t.includes('Zurück zum Warenkorb'))
assert.ok(t.includes('Wir unterwerfen uns keinen besonderen Verhaltenskodizes.'))
assert.ok(t.includes('Die Zahlung erfolgt über den Zahlungsdienstleister Stripe.'))
assert.ok(t.includes('Kreditkarte, Apple Pay, Google Pay, Klarna, SEPA-Lastschrift'))
assert.ok(t.includes('Bei SEPA-Lastschrift versenden wir die Ware erst nach Eingang der Zahlung.'))
assert.ok(t.includes('Wir liefern in folgende Länder: Deutschland, Österreich.'))
assert.ok(t.includes('Die Lieferzeit beträgt 2–4 Werktage'))
assert.ok(t.includes('Die Waren bleiben bis zur vollständigen Bezahlung unser Eigentum.'))
assert.ok(t.includes('Es gelten die gesetzlichen Mängelhaftungsrechte.'))
assert.ok(t.includes('Wir speichern den Vertragstext nach Vertragsschluss nicht für Sie abrufbar.'))
assert.ok(t.includes('Die Vertragssprache ist Deutsch.'))
assert.ok(t.includes('ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG)'))
assert.ok(t.includes('Alle Preise enthalten die gesetzliche Umsatzsteuer.'))
assert.ok(t.includes('USt-IdNr.: DE123456789'))
assert.doesNotMatch(t, /ec\.europa\.eu|OS-Plattform|ODR/i)
})
test('AGB: Varianten Kleinunternehmer, nur Karte, Abholung, kein Versand', () => {
const e = basis()
e.kleinunternehmer = true
e.abholung = { aktiv: true, adresse: 'Laden, Hauptstraße 5', hinweis: 'Di–Fr 9–17 Uhr' }
e.versand.aktiv = false
const t = musterAgb(e)
assert.ok(t.includes('Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.'))
assert.ok(!t.includes('SEPA'), 'SEPA-Satz nur bei SEPA')
assert.ok(!t.includes('Wir liefern in folgende Länder'))
assert.ok(t.includes('Sie können die Waren auch bei uns abholen: Laden, Hauptstraße 5. Di–Fr 9–17 Uhr'))
assert.ok(t.includes('Bei den Zahlungsarten Kreditkarte, Apple Pay, Google Pay erhalten Sie die Bestellbestätigung per E-Mail unmittelbar nach erfolgreicher Zahlung'))
})
test('AGB § 2 I-1: nur SEPA - keine Sofort-Bestätigung, nur Eingangsmail und Vertrag nach Zahlungseingang', () => {
const e = basis()
e.zahlungsarten = ['sepa_debit']
const t = musterAgb(e)
assert.doesNotMatch(t, /unmittelbar nach erfolgreicher Zahlung/)
assert.ok(t.includes('Bei SEPA-Lastschrift senden wir Ihnen zunächst eine E-Mail über den Eingang Ihrer Bestellung;'))
})
test('I-4: Steuerart noch nicht gewählt (null) rechnet in den Mustern wie Regelbesteuerung, Lieferzeit leer ergibt Platzhalter', () => {
const e = basis()
e.kleinunternehmer = null
e.lieferzeit = ''
assert.ok(musterAgb(e).includes('Alle Preise enthalten die gesetzliche Umsatzsteuer.'))
assert.ok(musterAgb(e).includes('Die Lieferzeit beträgt [Lieferzeit]'))
assert.ok(versandUndZahlung(e).includes('Alle Preise inkl. gesetzlicher USt.'))
})
test('versandUndZahlung: Liefergebiet, Kosten je Land, frei ab, Abholung, Zahlungsarten, Mindestbestellwert', () => {
const e = basis()
e.versand = { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 }
e.abholung = { aktiv: true, adresse: 'Laden, Hauptstraße 5', hinweis: 'Di–Fr 9–17 Uhr' }
e.zahlungsarten = ['card', 'klarna']
e.mindestbestellCent = 1000
e.kleinunternehmer = false
const t = versandUndZahlung(e)
assert.ok(t.includes('Liefergebiet: Deutschland, Österreich'))
assert.ok(t.includes(`Deutschland: 4,90${NB}€`))
assert.ok(t.includes(`Österreich: 9,90${NB}€`))
assert.ok(t.includes(`Versandkostenfrei ab einem Bestellwert von 50,00${NB}€.`))
assert.ok(t.includes('Abholung: Laden, Hauptstraße 5\nDi–Fr 9–17 Uhr'))
assert.ok(t.includes('Lieferzeit: 2–4 Werktage'))
assert.ok(t.includes('Zahlungsarten: Kreditkarte, Apple Pay, Google Pay, Klarna'))
assert.ok(t.includes(`Mindestbestellwert: 10,00${NB}€`))
assert.ok(t.includes('Alle Preise inkl. gesetzlicher USt.'))
e.kleinunternehmer = true
e.mindestbestellCent = null
e.versand.freiAbCent = null
e.abholung.aktiv = false
const k = versandUndZahlung(e)
assert.ok(k.includes('§ 19 UStG'))
assert.ok(!k.includes('Mindestbestellwert'))
assert.ok(!k.includes('Versandkostenfrei'))
assert.ok(!k.includes('Abholung'))
e.versand.aktiv = false
assert.ok(versandUndZahlung(e).includes('Wir versenden derzeit nicht.'))
})
test('zahlungsartenText: feste Reihenfolge, nur aktive Arten', () => {
assert.equal(zahlungsartenText({ zahlungsarten: ['card'] }), 'Kreditkarte, Apple Pay, Google Pay')
assert.equal(zahlungsartenText({ zahlungsarten: ['klarna', 'card'] }), 'Kreditkarte, Apple Pay, Google Pay, Klarna')
assert.equal(zahlungsartenText({ zahlungsarten: ['sepa_debit', 'klarna'] }), 'Klarna, SEPA-Lastschrift')
assert.equal(zahlungsartenText({ zahlungsarten: ['paypal', 'card'] }), 'Kreditkarte, Apple Pay, Google Pay')
assert.equal(zahlungsartenText({}), '')
assert.equal(zahlungsartenText(undefined), '')
})
test('Gewaehrleistungshinweis: zwei Jahre, stabil, ohne Platzhalter', () => {
const t = gewaehrleistungsHinweis()
assert.ok(t.startsWith('GESETZLICHE GEWÄHRLEISTUNG'))
assert.ok(t.includes('Mindestens zwei Jahre gesetzliche Gewährleistung der Vertragsmäßigkeit'))
assert.ok(t.includes('europa.eu/youreurope/garantien'))
assert.equal(gewaehrleistungsHinweis(), t)
})
test('BESTAETIGUNG_TEXT genau nach Spec 4.6', () => {
assert.equal(BESTAETIGUNG_TEXT, 'Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.')
})
test('normalisiereRechtstexte: gueltig, Entwurf mit leeren Texten, Bereinigung', () => {
const r = normalisiereRechtstexte({
agb: ' Zeile 1\r\nZeile 2​ \n\n\n\nZeile 3 ',
widerrufsbelehrung: 'B',
widerrufsformular: 'F',
impressumUrl: ' https://brot.example/impressum ',
datenschutzUrl: 'https://brot.example/datenschutz?x=1#a',
unbekannt: 'wird ignoriert',
})
assert.deepEqual(r, {
rechtstexte: {
agb: 'Zeile 1\nZeile 2\n\nZeile 3',
widerrufsbelehrung: 'B',
widerrufsformular: 'F',
impressumUrl: 'https://brot.example/impressum',
datenschutzUrl: 'https://brot.example/datenschutz?x=1#a',
},
})
const leer = normalisiereRechtstexte({})
assert.deepEqual(leer, { rechtstexte: { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' } })
assert.equal(vollstaendig(leer.rechtstexte), false)
})
test('normalisiereRechtstexte: Laengen (I-7: so kurz, dass die Bestätigungsmail nie Links statt Texte braucht)', () => {
assert.deepEqual([MAX_AGB, MAX_WIDERRUFSBELEHRUNG, MAX_WIDERRUFSFORMULAR], [20000, 12000, 3000])
assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(20000) }).rechtstexte)
assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(20001) }).fehler.agb)
assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(12000) }).rechtstexte)
assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(12001) }).fehler.widerrufsbelehrung)
assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(3000) }).rechtstexte)
assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(3001) }).fehler.widerrufsformular)
// die Muster passen mit grossem Abstand hinein
const e = basis()
assert.ok(musterAgb(e).length < MAX_AGB / 4 && musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).length < MAX_WIDERRUFSBELEHRUNG / 2 && musterWiderrufsformular(e).length < MAX_WIDERRUFSFORMULAR / 2)
})
test('normalisiereRechtstexte I-7: viele Sonderzeichen und Umbrüche, die das HTML der Mail sprengen würden, werden beim Speichern abgelehnt', () => {
assert.equal(maskierteLaenge('a&b<c>"\n\'x'), 1 + 5 + 1 + 4 + 1 + 4 + 6 + 4 + 5 + 1)
const r = normalisiereRechtstexte({ agb: '&'.repeat(10000), widerrufsbelehrung: '&'.repeat(2100) })
assert.match(r.fehler.rechtstexte, /zu lang für die Bestätigungs-E-Mail/)
// gleich viele normale Zeichen sind in Ordnung
assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(12000), widerrufsbelehrung: 'a'.repeat(6000) }).rechtstexte)
// Höchstlänge mit Umbruch alle 100 Zeichen bleibt unter dem Mail-Budget
const zeile = `${'x'.repeat(99)}\n`
const voll = normalisiereRechtstexte({ agb: zeile.repeat(200), widerrufsbelehrung: zeile.repeat(120), widerrufsformular: zeile.repeat(30) })
assert.equal(voll.fehler, undefined)
})
test('normalisiereRechtstexte: URLs nur https ohne Zugangsdaten und Leerraum', () => {
for (const u of ['http://x.example/a', 'javascript:alert(1)', 'ftp://x.example', '//x.example/a', 'x.example/impressum', 'https://user:pw@x.example/a', 'https://user@x.example/a', 'https://x.example/a b', 'https://', 'data:text/html,hi']) {
const r = normalisiereRechtstexte({ impressumUrl: u })
assert.ok(r.fehler?.impressumUrl, `muss abgelehnt werden: ${u}`)
}
assert.ok(normalisiereRechtstexte({ datenschutzUrl: 'https://' + 'a'.repeat(500) + '.example' }).fehler.datenschutzUrl)
assert.ok(normalisiereRechtstexte({ datenschutzUrl: 'https://x.example/datenschutz' }).rechtstexte)
assert.equal(normalisiereRechtstexte({ impressumUrl: '' }).rechtstexte.impressumUrl, '')
})
test('normalisiereRechtstexte: falsche Typen und Nicht-Objekte', () => {
for (const v of [null, undefined, 'x', 5, [], true]) assert.ok(normalisiereRechtstexte(v).fehler.rechtstexte)
const r = normalisiereRechtstexte({ agb: 5, widerrufsbelehrung: {}, impressumUrl: 7 })
assert.ok(r.fehler.agb && r.fehler.widerrufsbelehrung && r.fehler.impressumUrl)
assert.ok(!('rechtstexte' in r))
// Prototyp-Schluessel werden nie gelesen
const p = normalisiereRechtstexte(JSON.parse('{"__proto__":{"agb":"x"}}'))
assert.equal(p.rechtstexte.agb, '')
})
test('vollstaendig: alle drei Texte und beide URLs', () => {
const voll = { agb: 'a', widerrufsbelehrung: 'b', widerrufsformular: 'c', impressumUrl: 'https://x.example/i', datenschutzUrl: 'https://x.example/d' }
assert.equal(vollstaendig(voll), true)
for (const k of Object.keys(voll)) {
assert.equal(vollstaendig({ ...voll, [k]: '' }), false, k)
assert.equal(vollstaendig({ ...voll, [k]: ' ' }), false, `${k} nur Leerraum`)
}
assert.equal(vollstaendig(null), false)
assert.equal(vollstaendig(undefined), false)
})
test('rechtstexteHash: stabil, sha256 hex, reagiert auf jedes Feld und die Einstellungen', () => {
const e = basis()
const r = { agb: 'a', widerrufsbelehrung: 'b', widerrufsformular: 'c', impressumUrl: 'https://x.example/i', datenschutzUrl: 'https://x.example/d' }
const h = rechtstexteHash(r, e)
assert.match(h, /^[0-9a-f]{64}$/)
assert.equal(rechtstexteHash({ ...r }, JSON.parse(JSON.stringify(e))), h)
// Reihenfolge der Schluessel spielt keine Rolle
assert.equal(rechtstexteHash({ datenschutzUrl: r.datenschutzUrl, impressumUrl: r.impressumUrl, widerrufsformular: 'c', widerrufsbelehrung: 'b', agb: 'a' }, e), h)
// zusaetzliche Felder zaehlen nicht
assert.equal(rechtstexteHash({ ...r, extra: 'x' }, e), h)
for (const k of Object.keys(r)) assert.notEqual(rechtstexteHash({ ...r, [k]: `${r[k]}x` }, e), h, k)
// Versand-/Zahlungstext fliesst ein
const e2 = JSON.parse(JSON.stringify(e))
e2.versand.kostenCent.DE = 590
assert.notEqual(rechtstexteHash(r, e2), h)
const e3 = JSON.parse(JSON.stringify(e))
e3.zahlungsarten = ['card', 'klarna']
assert.notEqual(rechtstexteHash(r, e3), h)
// Felder ohne Einfluss auf den erzeugten Text aendern den Hash nicht
const e4 = JSON.parse(JSON.stringify(e))
e4.akzent = '#ff0000'
assert.equal(rechtstexteHash(r, e4), h)
// fehlende Felder wie leere Texte
assert.equal(rechtstexteHash({}, e), rechtstexteHash({ agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' }, e))
})
test('Markdown-Muster und Code teilen die Kernsaetze', () => {
const md = readFileSync(new URL('../docs/funktionen/shop-muster-rechtstexte.md', import.meta.url), 'utf8')
assert.ok(md.includes('Muster – keine Rechtsberatung'))
const e = basis()
e.zahlungsarten = ['card', 'klarna', 'sepa_debit']
e.kleinunternehmer = false
// Absaetze ohne Haendlerdaten muessen wortgleich im Markdown stehen
const absaetze = [
...musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).split('\n\n'),
...musterAgb(e).split('\n\n'),
...musterWiderrufsformular(e).split('\n\n'),
...gewaehrleistungsHinweis().split('\n\n'),
]
const ohneDaten = absaetze.filter((a) => !/Brot & Mehr|brot\.example|Hauptstra|Kaiserslautern|2–4 Werktage|0631|DE123456789/.test(a))
assert.ok(ohneDaten.length >= 25, `zu wenige vergleichbare Absaetze: ${ohneDaten.length}`)
const mdFlach = md.replace(/ {2}\n/g, '\n')
for (const a of ohneDaten) assert.ok(mdFlach.includes(a), `im Markdown fehlt: ${a.slice(0, 80)}`)
// Absaetze mit Haendlerdaten: Kernsaetze davon
for (const s of [
'Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab.',
'mit dieser E-Mail nehmen wir Ihre Bestellung an und der Vertrag kommt zustande.',
'Zum Vertragsschluss führen folgende Schritte:',
'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.',
'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung',
'ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG)',
'Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag',
]) {
assert.ok(md.includes(s), `Markdown: ${s}`)
}
assert.ok(md.includes('Wir tragen die Kosten der Rücksendung der Waren.'))
assert.doesNotMatch(md, /ec\.europa\.eu\/consumers\/odr/)
})
test('pruefeBestaetigbar I-5: Muster mit Platzhaltern, Kopfzeile oder ohne Adresse der Widerrufsfunktion lässt sich nicht bestätigen', () => {
const leer = JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN))
const roh = { agb: musterAgb(leer), widerrufsbelehrung: musterWiderrufsbelehrung(leer, {}), widerrufsformular: musterWiderrufsformular(leer) }
const p = pruefeBestaetigbar(roh, { widerrufUrl: WIDERRUF_URL })
const alle = p.join('\n')
for (const s of ['[Name]', '[Anschrift]', '[E-Mail-Adresse]', '[Internetadresse der Widerrufsfunktion]', '[Lieferzeit]', MUSTER_KOPFZEILE, WIDERRUF_URL]) assert.ok(alle.includes(s), s)
assert.match(alle, /^In den AGB stehen noch Platzhalter/m)
assert.match(alle, /dem Muster-Widerrufsformular/)
// ausgefuellt, ohne Kopfzeile, mit Adresse: keine Probleme
const e = basis()
const gut = {
agb: musterAgb(e).replace(`${MUSTER_KOPFZEILE}\n\n`, ''),
widerrufsbelehrung: musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }),
widerrufsformular: musterWiderrufsformular(e),
}
assert.deepEqual(pruefeBestaetigbar(gut, { widerrufUrl: WIDERRUF_URL }), [])
// nur die Kopfzeile
assert.equal(pruefeBestaetigbar({ ...gut, agb: musterAgb(e) }, { widerrufUrl: WIDERRUF_URL }).length, 1)
// Belehrung ohne die Adresse
const ohneUrl = pruefeBestaetigbar({ ...gut, widerrufsbelehrung: gut.widerrufsbelehrung.replace(WIDERRUF_URL, 'https://anders.example/') }, { widerrufUrl: WIDERRUF_URL })
assert.equal(ohneUrl.length, 1)
assert.match(ohneUrl[0], /Adresse deiner Widerrufsfunktion enthalten: https:\/\/brot\.example\/shop#widerruf/)
// ohne bekannte Adresse wird sie nicht verlangt; beliebige Angabe in eckigen Klammern zaehlt, einzelne Zeichen nicht
assert.deepEqual(pruefeBestaetigbar(gut, {}), [])
assert.equal(pruefeBestaetigbar({ ...gut, agb: `${gut.agb}\n[Zahlungsarten]` }, { widerrufUrl: WIDERRUF_URL }).length, 1)
assert.deepEqual(pruefeBestaetigbar({ ...gut, agb: `${gut.agb}\nSiehe Anlage [1]` }, { widerrufUrl: WIDERRUF_URL }), [])
// fehlende Texte stoeren hier nicht (das prueft vollstaendig)
assert.doesNotThrow(() => pruefeBestaetigbar(null))
assert.deepEqual(pruefeBestaetigbar({}, { widerrufUrl: WIDERRUF_URL }), [])
})
+695
View File
@@ -0,0 +1,695 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync, existsSync } from 'node:fs'
import { readdir, rm } from 'node:fs/promises'
import sharp from 'sharp'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Webshop C7: Verwaltungs-Routen /api/shop (Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder), Kundenansicht
process.env.SHOP_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'shop-routes-'))
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
customerViewLog: schemaKeys('customerViewLog'),
},
datetimes: SHOP_DATETIMES,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, send, post } = await import('./support/testApp.js')
const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js')
const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const sf = await import('../server/services/siteFeatures.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { shopPerSiteKey } = await import('../server/services/shop/kontext.js')
const { archivId } = await import('../server/services/shop/archiv.js')
const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Töpferei', archived: false })
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' })
aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Altprojekt', archived: true })
const cookie = customerCookie('c1')
const KV = { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } }
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
kleinunternehmer: true,
lieferzeit: '2–4 Werktage',
shopname: 'Töpferei Keramik',
anbieter: { name: 'Töpferei Keramik', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@toepferei.example', telefon: '', ustId: '' },
}
const TEXTE = {
agb: 'Unsere AGB.',
widerrufsbelehrung: 'Unsere Widerrufsbelehrung.',
widerrufsformular: 'Unser Widerrufsformular.',
impressumUrl: 'https://toepferei.example/impressum',
datenschutzUrl: 'https://toepferei.example/datenschutz',
}
const ARTIKEL = { name: 'Tasse', preisCent: 1800 }
/** Bestaetigungsfaehige Texte: die Belehrung nennt die Adresse der Widerrufsfunktion dieses Shops (I-5). */
const WIDERRUF = (feature) => `${config.publicBaseUrl}/shop/${feature.siteKey}/widerruf`
const texteFuer = (feature) => ({ ...TEXTE, widerrufsbelehrung: `Unsere Widerrufsbelehrung. Widerruf online: ${WIDERRUF(feature)}` })
const q = (projectId = 'pt1') => `projectId=${projectId}`
const lesen = (pfad, projectId = 'pt1', opts = { cookie }) => send('GET', `/api/shop/${pfad}${pfad.includes('?') ? '&' : '?'}${q(projectId)}`, opts)
const schreiben = (method, pfad, body = {}, opts = { cookie }) => send(method, `/api/shop/${pfad}`, { ...opts, body: { projectId: 'pt1', ...body } })
async function aktiviere(projectId = 'pt1', state = 'active') {
const feature = await sf.ensureFeatureDoc('c1', projectId, 'shop')
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields(state))
return sf.getFeatureDoc('c1', projectId, 'shop')
}
const shopConfigDoc = (feature) => aw.docs('shopConfig').get(feature.$id)
const dateien = async (siteKey) => (await readdir(path.join(process.env.SHOP_UPLOAD_DIR, siteKey)).catch(() => [])).sort()
async function png(w = 64, h = 48) {
return sharp({ create: { width: w, height: h, channels: 3, background: '#c63' } }).png().toBuffer()
}
async function lade(buffer, { projectId = 'pt1', opts = { cookie }, type = 'application/octet-stream' } = {}) {
const res = await fetch(`${app.url}/api/shop/bild?projectId=${projectId}`, {
method: 'POST',
headers: { 'content-type': type, ...(opts.cookie ? { cookie: opts.cookie } : {}), ...(opts.headers || {}) },
body: buffer,
})
const text = await res.text()
let body = text
try {
body = JSON.parse(text)
} catch {
/* Text */
}
return { status: res.status, body }
}
/** Shop bis "bereit zum Oeffnen": active, Einstellungen, Rechtstexte bestaetigt, Stripe bereit, ein Artikel. */
async function machBereit({ mitArtikel = true } = {}) {
const feature = await aktiviere()
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 200)
if (mitArtikel) assert.equal((await schreiben('POST', 'produkte', { produkt: ARTIKEL })).status, 201)
assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: texteFuer(feature) })).status, 200)
const hash = (await lesen('rechtstexte')).body.hash
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash })).status, 200)
await updateDocument(config.collections.shopConfig, feature.$id, {
stripeAccountId: 'acct_test1',
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true, details_submitted: true, currently_due: [], past_due: [], disabled_reason: null, geheim: 'nie', geprueftAt: '2026-10-05T10:00:00.000Z' }),
})
return feature
}
let origin
beforeEach(async () => {
origin ??= { mode: config.shop.mode, pilotEmails: config.shop.pilotEmails }
config.shop.mode = 'pilot'
config.shop.pilotEmails = [customers.c1.email]
resetShopRoutesForTests()
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopRechtstexteArchiv', 'customerViewLog']) {
for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id)
}
await rm(process.env.SHOP_UPLOAD_DIR, { recursive: true, force: true })
const { leereCache } = await import('../server/services/shop/kontext.js')
leereCache()
})
// ---------------------------------------------------------------- Zugang
test('Zugang: ohne Session 401, ohne Projekt 400, fremdes oder unbekanntes Projekt 403, Schalter aus 409, Funktion nicht eingerichtet 409', async () => {
await aktiviere()
assert.equal((await send('GET', '/api/shop/einstellungen?projectId=pt1')).status, 401)
assert.equal((await send('GET', '/api/shop/einstellungen', { cookie })).status, 400)
assert.equal((await lesen('einstellungen', 'pt3')).status, 403)
assert.equal((await lesen('einstellungen', 'gibtsnicht')).status, 403)
for (const pfad of ['einstellungen', 'produkte', 'rechtstexte']) assert.equal((await lesen(pfad, 'pt2')).status, 409, `${pfad}: pt2 hat keine Funktion`)
const keine = await lesen('einstellungen', 'pt2')
assert.deepEqual([keine.body.code, keine.body.error], ['nicht_eingerichtet', 'Der Webshop ist für dieses Projekt noch nicht eingerichtet.'])
config.shop.mode = 'off'
const aus = await lesen('einstellungen')
assert.deepEqual([aus.status, aus.body.code], [409, 'nicht_verfuegbar'])
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['jemand@example.invalid']
assert.equal((await lesen('produkte')).status, 409)
})
test('Zustaende: ordered und off => 409; disabled nur lesend; setup legt shopConfig an (ID = portalFeatures-ID, siteKey kopiert)', async () => {
const feature = await aktiviere('pt1', 'ordered')
assert.equal((await lesen('einstellungen')).status, 409)
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('setup'))
assert.equal(aw.docs('shopConfig').size, 0)
const setup = await lesen('einstellungen')
assert.equal(setup.status, 200)
assert.equal(setup.body.einstellungen.lieferzeit, '')
assert.equal(setup.body.einstellungen.kleinunternehmer, null)
assert.equal(setup.body.vollstaendig, false)
const angelegt = shopConfigDoc(feature)
assert.deepEqual([angelegt.customerId, angelegt.projectId, angelegt.siteKey], ['c1', 'pt1', feature.siteKey])
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 200, 'setup darf schreiben')
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('disabled'))
for (const pfad of ['einstellungen', 'produkte', 'rechtstexte']) assert.equal((await lesen(pfad)).status, 200, `${pfad} lesbar`)
for (const [m, pfad, body] of [
['PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }], ['POST', 'produkte', { produkt: ARTIKEL }], ['PUT', 'rechtstexte', { rechtstexte: TEXTE }],
['POST', 'oeffnen', { offen: false }], ['POST', 'produkte/sortierung', { ids: [] }],
]) {
const res = await schreiben(m, pfad, body)
assert.deepEqual([res.status, res.body.code], [409, 'nicht_eingerichtet'], `${m} ${pfad}`)
}
assert.equal((await lesen('einstellungen', 'pt4')).status, 409)
})
test('archiviertes Projekt: Lesen ja (falls Funktion da), Schreiben 409 wie bei Bestellungen', async () => {
const feature = await sf.ensureFeatureDoc('c1', 'pt4', 'shop')
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('active'))
assert.equal((await lesen('einstellungen', 'pt4')).status, 200)
const res = await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN, projectId: 'pt4' })
assert.equal(res.status, 409)
})
// ---------------------------------------------------------------- Status
test('GET /status ohne Funktion: gleiche Form mit leeren Werten, legt nichts an', async () => {
const res = await lesen('status')
assert.equal(res.status, 200)
assert.deepEqual(res.body, {
verfuegbar: true, state: 'off', ticketWoid: null, anfrage: null, bereitschaft: null, geoeffnet: false, schalter: false, siteKey: null,
snippet: null, vorschauUrl: null, stripe: { verbunden: false, status: null }, zahlen: null,
})
assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0)
assert.equal((await lesen('status', 'pt3')).status, 403)
})
test('GET /status mit Shop: Bereitschaft, Snippet, Vorschau, Stripe ohne Konto-ID und fremde Felder, Zahlen; geoeffnet erst nach Oeffnen', async () => {
const feature = await machBereit()
const res = await lesen('status')
assert.equal(res.status, 200)
const b = res.body
assert.deepEqual([b.verfuegbar, b.state, b.siteKey, b.schalter, b.geoeffnet], [true, 'active', feature.siteKey, false, false])
assert.equal(b.bereitschaft.bereit, true)
assert.deepEqual(b.bereitschaft.punkte.map((p) => [p.id, p.ok]), [['einbau', true], ['stripe', true], ['einstellungen', true], ['rechtstexte', true], ['artikel', true]])
assert.equal(b.snippet, `<div data-webklar-shop="${feature.siteKey}"></div>\n<script src="${config.publicBaseUrl}/embed/shop.js" defer></script>`)
assert.equal(b.vorschauUrl, `${config.publicBaseUrl}/shop/${feature.siteKey}`)
assert.deepEqual(b.stripe, {
verbunden: true,
status: { charges_enabled: true, payouts_enabled: true, details_submitted: true, currently_due: [], past_due: [], disabled_reason: null, geprueftAt: '2026-10-05T10:00:00.000Z' },
})
assert.ok(!JSON.stringify(b).includes('acct_test1') && !JSON.stringify(b).includes('geheim'))
assert.deepEqual(b.zahlen, { artikel: 1, aktiveArtikel: 1, offeneBestellungen: 0, bestellungen30: 0, umsatz30Cent: 0 })
assert.equal(b.ticketWoid, null)
assert.equal((await schreiben('POST', 'oeffnen', { offen: true })).status, 200)
assert.equal((await lesen('status')).body.geoeffnet, true)
assert.equal((await lesen('status')).body.schalter, true)
})
// ---------------------------------------------------------------- Einstellungen
test('Einstellungen: speichern, Version zaehlt, veraltete Version 409, Fehler je Feld 400', async () => {
await aktiviere()
const leer = await lesen('einstellungen')
assert.equal(leer.body.version, 0)
const gut = await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN, version: 0 })
assert.equal(gut.status, 200)
assert.deepEqual([gut.body.version, gut.body.vollstaendig, gut.body.einstellungen.shopname], [1, true, 'Töpferei Keramik'])
assert.equal((await lesen('einstellungen')).body.einstellungen.anbieter.ort, 'Kaiserslautern')
const veraltet = await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN, version: 0 })
assert.deepEqual([veraltet.status, veraltet.body.code], [409, 'EINSTELLUNG_VERALTET'])
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 200, 'ohne Version keine Pruefung')
const schlecht = await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, akzent: 'rot' } })
assert.equal(schlecht.status, 400)
assert.ok(schlecht.body.fehler && Object.keys(schlecht.body.fehler).length > 0)
assert.equal(schlecht.body.error, Object.values(schlecht.body.fehler)[0])
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: 'text' })).status, 400)
})
test('Einstellungen speichern leert den 30-s-Speicher von shopPerSiteKey', async () => {
const feature = await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
const vorher = await shopPerSiteKey(feature.siteKey)
assert.equal(vorher.shop.einstellungen.shopname, 'Töpferei Keramik')
await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, shopname: 'Neu' } })
assert.equal((await shopPerSiteKey(feature.siteKey)).shop.einstellungen.shopname, 'Neu')
})
// ---------------------------------------------------------------- Artikel
test('Artikel I3: Speichern ohne Bestandsaenderung behaelt den inzwischen verkauften Bestand, geaenderter Bestand wird geschrieben', async () => {
await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
const neu = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bestand: 5, bestandGeladen: 5 } })
assert.equal(neu.status, 201)
const id = neu.body.produkt.id
assert.equal(neu.body.produkt.bestand, 5)
assert.equal(neu.body.produkt.bestandGeladen, undefined)
// Zwischenzeitlich wurden 3 Stueck verkauft
const doc = aw.docs('shopProducts').get(id)
await updateDocument(config.collections.shopProducts, id, { produkt: JSON.stringify({ ...JSON.parse(doc.produkt), bestand: 2 }) })
const nurText = await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, name: 'Tasse 2', bestand: 5, bestandGeladen: 5 } })
assert.equal(nurText.status, 200)
assert.deepEqual([nurText.body.produkt.name, nurText.body.produkt.bestand], ['Tasse 2', 2])
assert.ok(!aw.docs('shopProducts').get(id).produkt.includes('bestandGeladen'))
const aufgestockt = await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, bestand: 12, bestandGeladen: 5 } })
assert.equal(aufgestockt.body.produkt.bestand, 12)
const ohneMarker = await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, bestand: 7 } })
assert.equal(ohneMarker.body.produkt.bestand, 7, 'alter Browser ohne bestandGeladen: wie bisher')
})
test('Artikel: anlegen 201, lesen, aendern, Fehler 400 je Feld, unbekannte ID 404, Kleinunternehmer-Steuer', async () => {
await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }) // Kleinunternehmer => 0 %
const neu = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, kurz: 'Handgemacht', extra: 'ignoriert' } })
assert.equal(neu.status, 201)
const id = neu.body.produkt.id
assert.deepEqual([neu.body.produkt.name, neu.body.produkt.preisCent, neu.body.produkt.steuersatz, neu.body.produkt.aktiv, neu.body.produkt.sort], ['Tasse', 1800, 0, true, 0])
assert.equal(neu.body.produkt.extra, undefined)
const liste = await lesen('produkte')
assert.equal(liste.status, 200)
assert.deepEqual(liste.body.produkte.map((p) => p.id), [id])
assert.deepEqual([liste.body.maxArtikel, liste.body.maxBilder], [config.shop.maxArtikel, config.shop.maxBilder])
const geaendert = await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, name: 'Große Tasse', aktiv: false } })
assert.equal(geaendert.status, 200)
assert.deepEqual([geaendert.body.produkt.id, geaendert.body.produkt.name, geaendert.body.produkt.aktiv], [id, 'Große Tasse', false])
assert.equal((await lesen('produkte')).body.produkte[0].aktiv, false)
assert.equal(aw.docs('shopProducts').size, 1)
const schlecht = await schreiben('POST', 'produkte', { produkt: { name: '', preisCent: -5 } })
assert.equal(schlecht.status, 400)
assert.ok(schlecht.body.fehler.name && schlecht.body.fehler.preisCent)
assert.equal((await schreiben('POST', 'produkte', { produkt: 'text' })).status, 400)
assert.equal((await schreiben('POST', 'produkte', {})).status, 400)
assert.equal((await schreiben('PUT', 'produkte/gibtsnicht', { produkt: ARTIKEL })).status, 404)
assert.equal((await schreiben('DELETE', 'produkte/gibtsnicht')).status, 404)
assert.equal(aw.docs('shopProducts').size, 1)
})
test('Artikel mit Regelbesteuerung: Steuersatz kommt aus den gespeicherten Einstellungen', async () => {
await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, kleinunternehmer: false } })
const res = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, steuersatz: 7 } })
assert.equal(res.body.produkt.steuersatz, 7)
})
test('Artikel gehoeren zu ihrem Shop: fremder Shop kann sie weder sehen noch aendern', async () => {
await aktiviere()
const neu = await schreiben('POST', 'produkte', { produkt: ARTIKEL })
await aktiviere('pt2')
assert.deepEqual((await lesen('produkte', 'pt2')).body.produkte, [])
assert.equal((await schreiben('PUT', `produkte/${neu.body.produkt.id}`, { produkt: ARTIKEL, projectId: 'pt2' })).status, 404)
assert.equal((await schreiben('DELETE', `produkte/${neu.body.produkt.id}`, {}, { cookie }) ).status, 200)
})
test('Sortierung: neue Reihenfolge, falsche Liste 400', async () => {
await aktiviere()
const ids = []
for (const name of ['A', 'B', 'C']) ids.push((await schreiben('POST', 'produkte', { produkt: { name, preisCent: 100 } })).body.produkt.id)
const res = await schreiben('POST', 'produkte/sortierung', { ids: [ids[2], ids[0], ids[1]] })
assert.equal(res.status, 200)
assert.deepEqual(res.body.produkte.map((p) => p.name), ['C', 'A', 'B'])
assert.deepEqual((await lesen('produkte')).body.produkte.map((p) => p.name), ['C', 'A', 'B'])
assert.equal((await schreiben('POST', 'produkte/sortierung', { ids: [ids[0]] })).status, 400)
assert.equal((await schreiben('POST', 'produkte/sortierung', {})).status, 400)
})
// ---------------------------------------------------------------- Bilder
test('Bild hochladen: 201, Sidecar mit Shop und Kunde, Vorschau lesbar; Typfehler 415, zu gross 413, ohne Session 401, fremdes Projekt 403', async () => {
const feature = await aktiviere()
const res = await lade(await png(2000, 1000))
assert.equal(res.status, 201)
assert.deepEqual([res.body.w, res.body.h], [1600, 800])
assert.match(res.body.imageId, /^[a-z0-9-]{36}$/)
const dateiListe = await dateien(feature.siteKey)
assert.deepEqual(dateiListe, [`${res.body.imageId}-l.webp`, `${res.body.imageId}-s.webp`, `${res.body.imageId}.json`].sort())
const meta = JSON.parse(await (await import('node:fs/promises')).readFile(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${res.body.imageId}.json`), 'utf8'))
assert.deepEqual([meta.customerId, meta.shopId, meta.claimed], ['c1', feature.$id, false])
for (const datei of [`${res.body.imageId}-s.webp`, `${res.body.imageId}-l.webp`]) {
const bild = await fetch(`${app.url}/api/shop/bild/pt1/${datei}`, { headers: { cookie } })
assert.equal(bild.status, 200)
assert.equal(bild.headers.get('content-type'), 'image/webp')
assert.equal(bild.headers.get('x-content-type-options'), 'nosniff')
assert.equal(bild.headers.get('cache-control'), 'private, max-age=3600')
assert.equal((await sharp(Buffer.from(await bild.arrayBuffer())).metadata()).format, 'webp')
}
assert.equal((await lade(Buffer.from('%PDF-1.4 kein Bild'))).status, 415)
assert.equal((await lade(await png(), { type: 'text/plain' })).status, 415, 'kein octet-stream')
const zuGross = await lade(Buffer.alloc(8 * 1024 * 1024 + 1))
assert.deepEqual([zuGross.status, zuGross.body.error], [413, 'Das Bild ist größer als 8 MB.'])
assert.equal((await lade(await png(), { opts: {} })).status, 401)
assert.equal((await lade(await png(), { projectId: 'pt3' })).status, 403)
assert.equal((await lade(await png(), { projectId: 'pt2' })).status, 409, 'pt2 hat keine Funktion')
assert.equal((await dateien(feature.siteKey)).length, 3, 'nichts Weiteres abgelegt')
})
test('Bild-Vorschau: nur eigene Projekte, nur gueltige Dateinamen, nur vorhandene Bilder', async () => {
const feature = await aktiviere()
const { body } = await lade(await png())
const url = (projekt, datei) => `${app.url}/api/shop/bild/${projekt}/${datei}`
const hole = (projekt, datei, opts = { cookie }) => fetch(url(projekt, datei), { headers: opts.cookie ? { cookie: opts.cookie } : {} })
assert.equal((await hole('pt1', `${body.imageId}-s.webp`, {})).status, 401)
assert.equal((await hole('pt3', `${body.imageId}-s.webp`)).status, 403)
assert.equal((await hole('pt1', `${body.imageId}-xl.webp`)).status, 404)
assert.equal((await hole('pt1', `${body.imageId}.json`)).status, 404, 'Sidecar wird nicht ausgeliefert')
assert.equal((await hole('pt1', '..%2F..%2Fetc%2Fpasswd')).status, 404)
assert.equal((await hole('pt1', 'gibtsnicht-s.webp')).status, 404)
assert.equal((await hole('pt1', `${body.imageId}-s.webp`, { cookie: customerCookie('c2') })).status, 403)
// Datei ohne Sidecar gilt als nicht vorhanden
await rm(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${body.imageId}.json`))
assert.equal((await hole('pt1', `${body.imageId}-s.webp`)).status, 404)
})
test('Bilder: 40 Uploads je Stunde, der 41. wird mit 429 abgelehnt', async () => {
await aktiviere()
const klein = await png(8, 8)
for (let i = 0; i < 40; i++) assert.equal((await lade(klein)).status, 201, `Upload ${i + 1}`)
const zuViele = await lade(klein)
assert.deepEqual([zuViele.status, zuViele.body.error], [429, 'Zu viele Bilder in kurzer Zeit. Bitte versuch es später wieder.'])
assert.equal((await lade(klein, { opts: { cookie: customerCookie('c1') } })).status, 429)
})
test('Artikel mit Bildern: unbekannte oder fremde ID 400, Beanspruchen, Entfernen loescht, geteiltes Bild bleibt bis zuletzt, Loeschen des Artikels raeumt auf', async () => {
const feature = await aktiviere()
const a = (await lade(await png())).body.imageId
const b = (await lade(await png())).body.imageId
const c = (await lade(await png())).body.imageId
const meta = async (id) => JSON.parse(await (await import('node:fs/promises')).readFile(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${id}.json`), 'utf8'))
const da = (id) => existsSync(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${id}-l.webp`))
const unbekannt = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: [a, 'gibtsnicht'] } })
assert.equal(unbekannt.status, 400)
assert.ok(unbekannt.body.fehler.bilder)
assert.equal(aw.docs('shopProducts').size, 0)
assert.equal((await meta(a)).claimed, false, 'nichts beansprucht, wenn der Artikel nicht gespeichert wurde')
// Bild eines anderen Shops
const f2 = await aktiviere('pt2')
const fremd = (await lade(await png(), { projectId: 'pt2' })).body.imageId
const mitFremd = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: [fremd] } })
assert.equal(mitFremd.status, 400)
assert.ok(existsSync(path.join(process.env.SHOP_UPLOAD_DIR, f2.siteKey, `${fremd}-l.webp`)))
const mitPfad = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: ['../x'] } })
assert.equal(mitPfad.status, 400)
const p1 = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: [a, b] } })
assert.equal(p1.status, 201)
assert.deepEqual(p1.body.produkt.bilder, [a, b])
assert.deepEqual(p1.body.produkt.bildUrls[0], { id: a, klein: `/api/shop/bild/pt1/${a}-s.webp`, gross: `/api/shop/bild/pt1/${a}-l.webp` })
assert.equal((await meta(a)).claimed, true)
assert.equal((await meta(b)).claimed, true)
assert.equal((await meta(c)).claimed, false)
const p2 = await schreiben('POST', 'produkte', { produkt: { name: 'Zweiter', preisCent: 500, bilder: [b] } })
assert.equal(p2.status, 201)
// a entfernen => weg; b entfernen aus p1, aber p2 nutzt es noch => bleibt
const ohne = await schreiben('PUT', `produkte/${p1.body.produkt.id}`, { produkt: { ...ARTIKEL, bilder: [] } })
assert.equal(ohne.status, 200)
assert.equal(da(a), false)
assert.equal(existsSync(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${a}.json`)), false)
assert.equal(da(b), true, 'p2 nutzt b noch')
assert.equal(da(c), true, 'unbenutzt hochgeladene Bilder raeumt nur der Nachtjob auf')
// p2 loeschen => b weg
assert.equal((await schreiben('DELETE', `produkte/${p2.body.produkt.id}`)).status, 200)
assert.equal(da(b), false)
assert.equal(aw.docs('shopProducts').size, 1)
// Reihenfolge und Anzahl der Bilder bleiben, mehr als maxBilder abgelehnt
const viele = []
for (let i = 0; i < config.shop.maxBilder + 1; i++) viele.push((await lade(await png(8, 8))).body.imageId)
const zuViele = await schreiben('PUT', `produkte/${p1.body.produkt.id}`, { produkt: { ...ARTIKEL, bilder: viele } })
assert.equal(zuViele.status, 400)
assert.ok(zuViele.body.fehler.bilder)
const ok = await schreiben('PUT', `produkte/${p1.body.produkt.id}`, { produkt: { ...ARTIKEL, bilder: viele.slice(0, 2).reverse() } })
assert.deepEqual(ok.body.produkt.bilder, viele.slice(0, 2).reverse())
})
// ---------------------------------------------------------------- Rechtstexte
test('Rechtstexte GET: Entwurf leer, nichts bestaetigt, Muster, erzeugte Texte, Gewaehrleistung, Hash, Bestaetigungstext', async () => {
const feature = await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
const res = await lesen('rechtstexte')
assert.equal(res.status, 200)
const b = res.body
assert.deepEqual(Object.keys(b).sort(), ['bestaetigt', 'bestaetigungText', 'entwurf', 'gewaehrleistung', 'hash', 'muster', 'probleme', 'versandUndZahlung', 'vollstaendig'])
assert.deepEqual(b.entwurf, { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' })
assert.equal(b.bestaetigt, null)
assert.equal(b.vollstaendig, false)
assert.deepEqual(b.probleme, [], 'leerer Entwurf: nur vollstaendig meldet etwas')
assert.match(b.hash, /^[0-9a-f]{64}$/)
assert.equal(b.hash, rechtstexteHash(b.entwurf, (await lesen('einstellungen')).body.einstellungen))
for (const k of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) assert.ok(b.muster[k].length > 100, k)
assert.ok(b.muster.agb.includes('Töpferei Keramik'))
assert.ok(b.muster.widerrufsbelehrung.includes(`${config.publicBaseUrl}/shop/${feature.siteKey}/widerruf`))
assert.ok(b.versandUndZahlung.includes('Versand'))
assert.deepEqual([b.gewaehrleistung.bild, b.gewaehrleistung.text.startsWith('GESETZLICHE GEWÄHRLEISTUNG')], ['/img/gewaehrleistung-eu.webp', true])
assert.match(b.bestaetigungText, /^Ich habe die Texte geprüft/)
})
test('Rechtstexte PUT: speichert den Entwurf, normalisiert, Fehler 400; Hash aendert sich mit dem Text', async () => {
const feature = await aktiviere()
const leer = (await lesen('rechtstexte')).body.hash
const res = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, agb: ' Neue AGB \r\n\r\n\r\n\r\nZeile', extra: 'x' } })
assert.equal(res.status, 200)
assert.equal(res.body.entwurf.agb, 'Neue AGB\n\nZeile')
assert.equal(res.body.vollstaendig, true)
assert.notEqual(res.body.hash, leer)
assert.equal((await lesen('rechtstexte')).body.entwurf.widerrufsformular, 'Unser Widerrufsformular.')
assert.equal(JSON.parse(shopConfigDoc(feature).rechtstexte).bestaetigt, null)
const schlecht = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, impressumUrl: 'http://unsicher.example', datenschutzUrl: 'javascript:alert(1)' } })
assert.equal(schlecht.status, 400)
assert.ok(schlecht.body.fehler.impressumUrl && schlecht.body.fehler.datenschutzUrl)
assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: 'text' })).status, 400)
assert.equal((await schreiben('PUT', 'rechtstexte', {})).status, 400)
const zuLang = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, agb: 'x'.repeat(20001) } })
assert.equal(zuLang.status, 400)
assert.equal((await lesen('rechtstexte')).body.entwurf.agb, 'Neue AGB\n\nZeile', 'Fehler aendert nichts')
})
test('Rechtstexte bestaetigen: falscher oder alter Hash 409, unvollstaendig 400, sonst Kopie mit Zeit, Person und Hash', async () => {
const feature = await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
const unvollstaendig = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...texteFuer(feature), datenschutzUrl: '' } })
const hashUnvollstaendig = unvollstaendig.body.hash
const leer = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: hashUnvollstaendig })
assert.equal(leer.status, 400)
assert.equal(shopConfigDoc(feature).rechtstexteHash || '', '')
const voll = await schreiben('PUT', 'rechtstexte', { rechtstexte: texteFuer(feature) })
const falsch = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: 'abc' })
assert.deepEqual([falsch.status, falsch.body.code], [409, 'texte_geaendert'])
assert.match(falsch.body.error, /^Die Texte wurden inzwischen geändert/)
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', {})).status, 409)
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: hashUnvollstaendig })).status, 409, 'Hash des frueheren Entwurfs')
const ok = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: voll.body.hash })
assert.equal(ok.status, 200)
assert.deepEqual(ok.body.bestaetigt.texte, texteFuer(feature))
assert.equal(ok.body.bestaetigt.von, `${customers.c1.name} <${customers.c1.email}>`)
assert.equal(ok.body.bestaetigt.aktuell, true)
assert.ok(Math.abs(Date.now() - Date.parse(ok.body.bestaetigt.at)) < 10_000)
const doc = shopConfigDoc(feature)
assert.equal(doc.rechtstexteHash, voll.body.hash)
assert.equal(doc.rechtstexteBestaetigtVon, `${customers.c1.name} <${customers.c1.email}>`)
assert.equal(new Date(doc.rechtstexteBestaetigtAt).toISOString(), new Date(ok.body.bestaetigt.at).toISOString())
const gespeichert = JSON.parse(doc.rechtstexte)
assert.deepEqual(gespeichert.entwurf, texteFuer(feature))
assert.deepEqual([gespeichert.bestaetigt.texte, gespeichert.bestaetigt.hash], [texteFuer(feature), voll.body.hash])
assert.equal((await shopPerSiteKey(feature.siteKey)).shop.rechtstexte.bestaetigt.hash, voll.body.hash, 'Speicher wurde geleert')
// I-6: die bestaetigte Fassung liegt im Archiv (eigene ID, Texte samt Versand/Zahlung und Gewaehrleistung, Person und Zeit)
const archiv = [...aw.docs('shopRechtstexteArchiv').values()]
assert.equal(archiv.length, 1)
assert.equal(archiv[0].$id, archivId(feature.$id, voll.body.hash))
assert.deepEqual([archiv[0].shopId, archiv[0].customerId, archiv[0].hash, archiv[0].bestaetigtVon], [feature.$id, 'c1', voll.body.hash, `${customers.c1.name} <${customers.c1.email}>`])
assert.equal(new Date(archiv[0].bestaetigtAt).toISOString(), new Date(ok.body.bestaetigt.at).toISOString())
const archTexte = JSON.parse(archiv[0].texte)
assert.deepEqual({ ...archTexte, versandUndZahlung: undefined, gewaehrleistung: undefined }, { ...texteFuer(feature), versandUndZahlung: undefined, gewaehrleistung: undefined })
assert.equal(archTexte.versandUndZahlung, voll.body.versandUndZahlung)
assert.equal(archTexte.gewaehrleistung, voll.body.gewaehrleistung.text)
// Entwurf aendern: die bestaetigte Fassung bleibt, ist aber nicht mehr aktuell; Bereitschaft verlangt neue Bestaetigung
const geaendert = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...texteFuer(feature), agb: 'Geaenderte AGB' } })
assert.deepEqual(geaendert.body.bestaetigt.texte, texteFuer(feature))
assert.equal(geaendert.body.bestaetigt.aktuell, false)
assert.equal(geaendert.body.entwurf.agb, 'Geaenderte AGB')
const stand = (await lesen('status')).body.bereitschaft.punkte.find((p) => p.id === 'rechtstexte')
assert.equal(stand.ok, false)
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: voll.body.hash })).status, 409, 'alter Hash')
})
test('Rechtstexte: Hash haengt auch an den Einstellungen (Versand und Zahlung), eine Aenderung dort verlangt neue Bestaetigung', async () => {
const feature = await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
await schreiben('PUT', 'rechtstexte', { rechtstexte: texteFuer(feature) })
const alt = (await lesen('rechtstexte')).body.hash
await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, versand: { ...EINSTELLUNGEN.versand, kostenCent: { DE: 690 } } } })
const neu = (await lesen('rechtstexte')).body.hash
assert.notEqual(alt, neu)
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: alt })).status, 409)
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: neu })).status, 200)
})
test('Rechtstexte bestaetigen I-5: Platzhalter, Musterkopf und fehlende Adresse der Widerrufsfunktion ⇒ 400 nicht_bestaetigbar mit Liste; GET zeigt dieselben Probleme', async () => {
const feature = await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
const schlecht = { ...texteFuer(feature), agb: 'Muster – bitte prüfen und anpassen\n\nAGB von [Name], [Anschrift]', widerrufsbelehrung: 'Unsere Widerrufsbelehrung ohne Adresse.' }
const put = await schreiben('PUT', 'rechtstexte', { rechtstexte: schlecht })
assert.equal(put.status, 200)
assert.equal(put.body.probleme.length, 3)
assert.deepEqual((await lesen('rechtstexte')).body.probleme, put.body.probleme)
const res = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: put.body.hash })
assert.equal(res.status, 400)
assert.equal(res.body.code, 'nicht_bestaetigbar')
assert.deepEqual(res.body.probleme, put.body.probleme)
assert.match(res.body.probleme.join('\n'), /„\[Name\]“, „\[Anschrift\]“/)
assert.match(res.body.probleme.join('\n'), /Muster – bitte prüfen und anpassen/)
assert.ok(res.body.probleme.join('\n').includes(WIDERRUF(feature)))
assert.equal(shopConfigDoc(feature).rechtstexteHash || '', '', 'nichts bestaetigt')
// behoben ⇒ ohne Probleme und bestaetigbar
const gut = await schreiben('PUT', 'rechtstexte', { rechtstexte: texteFuer(feature) })
assert.deepEqual(gut.body.probleme, [])
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: gut.body.hash })).status, 200)
})
// ---------------------------------------------------------------- Oeffnen
test('Oeffnen: nur wenn bereit (sonst 409 mit Punkten), Schliessen immer, Speicher geleert, Eingabe geprueft', async () => {
const feature = await machBereit({ mitArtikel: false })
const nicht = await schreiben('POST', 'oeffnen', { offen: true })
assert.deepEqual([nicht.status, nicht.body.code], [409, 'nicht_bereit'])
assert.deepEqual(nicht.body.punkte.filter((p) => !p.ok).map((p) => p.id), ['artikel'])
assert.ok(nicht.body.punkte.every((p) => typeof p.text === 'string'))
assert.equal(shopConfigDoc(feature).geoeffnet !== true, true)
assert.equal((await schreiben('POST', 'oeffnen', { offen: 'ja' })).status, 400)
assert.equal((await schreiben('POST', 'oeffnen', {})).status, 400)
await schreiben('POST', 'produkte', { produkt: ARTIKEL })
const vorher = await shopPerSiteKey(feature.siteKey)
assert.equal(vorher.shop.geoeffnet, false)
const auf = await schreiben('POST', 'oeffnen', { offen: true })
assert.equal(auf.status, 200)
assert.deepEqual([auf.body.schalter, auf.body.geoeffnet, auf.body.bereitschaft.bereit], [true, true, true])
assert.equal(shopConfigDoc(feature).geoeffnet, true)
assert.match(shopConfigDoc(feature).geoeffnetAt, /^\d{4}-\d\d-\d\d/)
assert.equal((await shopPerSiteKey(feature.siteKey)).shop.geoeffnet, true, 'Speicher geleert')
const zu = await schreiben('POST', 'oeffnen', { offen: false })
assert.deepEqual([zu.status, zu.body.schalter, zu.body.geoeffnet], [200, false, false])
assert.equal((await shopPerSiteKey(feature.siteKey)).shop.geoeffnet, false)
// Stripe faellt aus: Oeffnen scheitert, Schliessen geht trotzdem
await updateDocument(config.collections.shopConfig, feature.$id, { stripeStatus: JSON.stringify({ charges_enabled: false }) })
const stripeAus = await schreiben('POST', 'oeffnen', { offen: true })
assert.deepEqual([stripeAus.status, stripeAus.body.punkte.filter((p) => !p.ok).map((p) => p.id)], [409, ['stripe']])
assert.equal((await schreiben('POST', 'oeffnen', { offen: false })).status, 200)
})
// ---------------------------------------------------------------- Kundenansicht
test('Kundenansicht: Einstellungen, Artikel, Rechtstexte-Entwurf und Bilder erlaubt und protokolliert; Lesen ebenfalls', async () => {
const feature = await aktiviere()
assert.equal((await lesen('einstellungen', 'pt1', KV)).status, 200)
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }, KV)).status, 200)
const neu = await schreiben('POST', 'produkte', { produkt: ARTIKEL }, KV)
assert.equal(neu.status, 201)
const id = neu.body.produkt.id
assert.equal((await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, name: 'Kanne' } }, KV)).status, 200)
assert.equal((await schreiben('POST', 'produkte/sortierung', { ids: [id] }, KV)).status, 200)
assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE }, KV)).status, 200)
const bild = await lade(await png(), { opts: KV })
assert.equal(bild.status, 201)
assert.equal((await schreiben('DELETE', `produkte/${id}`, {}, KV)).status, 200)
assert.equal((await dateien(feature.siteKey)).length, 3, 'das hochgeladene Bild gehoert keinem Artikel und wartet auf den Nachtjob')
const eintraege = [...aw.docs('customerViewLog').values()]
assert.ok(eintraege.every((e) => e.action === 'shop_aenderung' && e.staffName === 'Andrej' && e.customerId === 'c1'))
assert.deepEqual(eintraege.map((e) => e.detail).sort(), [
'Artikel angelegt: Tasse', 'Artikel geändert: Kanne', 'Artikel gelöscht: Kanne', 'Artikel sortiert', 'Bild hochgeladen',
'Einstellungen gespeichert', 'Rechtstexte-Entwurf gespeichert',
].sort())
})
test('Kundenansicht: Anfrage, Oeffnen und Bestaetigen sind gesperrt (Waechter), Stripe-Pfade auch', async () => {
await machBereit()
const hash = (await lesen('rechtstexte')).body.hash
for (const [pfad, body] of [['oeffnen', { offen: true }], ['rechtstexte/bestaetigen', { hash }], ['anfrage', {}], ['stripe/verbinden', {}]]) {
const res = await schreiben('POST', pfad, body, KV)
assert.deepEqual([res.status, res.body.code], [403, 'view_blocked'], pfad)
}
assert.equal(shopConfigDoc(await sf.getFeatureDoc('c1', 'pt1', 'shop')).geoeffnet !== true, true)
})
test('Kundenansicht: der Router sperrt Oeffnen und Bestaetigen selbst, auch ohne Waechter (alte Ansicht und Kundenansicht)', async () => {
await machBereit()
const hash = (await lesen('rechtstexte')).body.hash
const express = (await import('express')).default
const { sessionMiddleware } = await import('../server/middleware/session.js')
const roh = express()
roh.use(sessionMiddleware())
roh.use(express.json())
roh.use('/api/shop', shopRoutes)
const server = await new Promise((resolve) => {
const s = roh.listen(0, '127.0.0.1', () => resolve(s))
})
try {
const url = `http://127.0.0.1:${server.address().port}`
for (const [pfad, body] of [['/api/shop/oeffnen', { projectId: 'pt1', offen: true }], ['/api/shop/rechtstexte/bestaetigen', { projectId: 'pt1', hash }]]) {
for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) {
const res = await fetch(url + pfad, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify(body) })
assert.equal(res.status, 403, pfad)
assert.equal((await res.json()).error, 'In der Kundenansicht kann das nicht geändert werden.')
}
}
} finally {
await new Promise((resolve) => server.close(resolve))
}
assert.equal(JSON.parse(shopConfigDoc(await sf.getFeatureDoc('c1', 'pt1', 'shop')).rechtstexte).bestaetigt.hash, hash, 'unveraendert')
assert.equal(shopConfigDoc(await sf.getFeatureDoc('c1', 'pt1', 'shop')).geoeffnet !== true, true)
})
// ---------------------------------------------------------------- Rate-Limit und Fehler
test('Schreib-Limit: 300 je Stunde und Kunde, danach 429; Lesen zaehlt nicht; anderer Kunde unberuehrt', async () => {
await aktiviere()
for (let i = 0; i < 300; i++) {
const res = await send('POST', '/api/shop/oeffnen', { cookie, body: { projectId: 'pt1' } })
assert.equal(res.status, 400, `Aufruf ${i + 1}`)
}
const zuViele = await send('POST', '/api/shop/oeffnen', { cookie, body: { projectId: 'pt1', offen: false } })
assert.deepEqual([zuViele.status, zuViele.body.error], [429, 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.'])
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 429)
assert.equal((await lesen('einstellungen')).status, 200)
assert.equal((await lesen('status')).status, 200)
assert.equal((await schreiben('POST', 'oeffnen', { offen: false }, { cookie: customerCookie('c2') })).status !== 429, true)
})
test('Fehlertexte von Appwrite verlassen den Server nie (500 ohne Rohtext)', async () => {
await aktiviere()
aw.failNext({ collection: 'shopProducts', method: 'GET', status: 500, message: 'GEHEIM interner Appwrite-Text' })
const res = await lesen('produkte')
assert.equal(res.status, 500)
assert.deepEqual(res.body, { error: 'Interner Serverfehler' })
assert.equal((await lesen('produkte')).status, 200)
})
+165
View File
@@ -0,0 +1,165 @@
import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import path from 'node:path'
import { config } from '../server/config.js'
import { shopFuerKunde, shopModus } from '../server/services/shop/schalter.js'
const SHOP_ENV = [
'SHOP_ENABLED',
'SHOP_PILOT_EMAILS',
'SHOP_UPLOAD_DIR',
'STRIPE_CONNECT_WEBHOOK_SECRET',
'APPWRITE_COLLECTION_SHOP_CONFIG',
'APPWRITE_COLLECTION_SHOP_PRODUCTS',
'APPWRITE_COLLECTION_SHOP_ORDERS',
'APPWRITE_COLLECTION_SHOP_WIDERRUFE',
]
let original
beforeEach(() => {
original = { ...config.shop }
})
afterEach(() => {
Object.assign(config.shop, original)
})
/** Frische config.js-Instanz mit genau diesen Shop-Umgebungsvariablen (alle anderen entfernt). */
let freshCounter = 0
async function freshConfig(env = {}) {
const saved = Object.fromEntries(SHOP_ENV.map((key) => [key, process.env[key]]))
for (const key of SHOP_ENV) delete process.env[key]
Object.assign(process.env, env)
try {
freshCounter += 1
return (await import(`../server/config.js?shop-test=${freshCounter}`)).config
} finally {
for (const key of SHOP_ENV) {
if (saved[key] === undefined) delete process.env[key]
else process.env[key] = saved[key]
}
}
}
test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => {
const fresh = await freshConfig()
assert.deepEqual(fresh.shop, {
mode: 'off',
pilotEmails: [],
fassung: '2026-10-05',
uploadDir: path.join(process.cwd(), 'upload-data', 'shop'),
connectWebhookSecret: '',
bestellAblaufMinuten: 30,
maxArtikel: 500,
maxBilder: 4,
maxVarianten: 30,
anonymisierenJahre: 3,
loeschTageNachAbschalten: 90,
unbezahltLoeschTage: 30,
formMinSekunden: 3,
minAntwortMs: 300,
})
assert.equal(fresh.collections.shopConfig, 'shopConfig')
assert.equal(fresh.collections.shopProducts, 'shopProducts')
assert.equal(fresh.collections.shopOrders, 'shopOrders')
assert.equal(fresh.collections.shopWiderrufe, 'shopWiderrufe')
})
test('Konfiguration: Umgebungsvariablen werden gelesen und bereinigt', async () => {
const fresh = await freshConfig({
SHOP_ENABLED: ' Pilot ',
SHOP_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ',
SHOP_UPLOAD_DIR: '/tmp/shop-bilder',
STRIPE_CONNECT_WEBHOOK_SECRET: 'whsec_connect',
APPWRITE_COLLECTION_SHOP_CONFIG: 'sConfig',
APPWRITE_COLLECTION_SHOP_PRODUCTS: 'sProducts',
APPWRITE_COLLECTION_SHOP_ORDERS: 'sOrders',
APPWRITE_COLLECTION_SHOP_WIDERRUFE: 'sWiderrufe',
})
assert.equal(fresh.shop.mode, 'pilot')
assert.deepEqual(fresh.shop.pilotEmails, ['kenso.gri@gmail.com', 'justin@webklar.com'])
assert.equal(fresh.shop.uploadDir, '/tmp/shop-bilder')
assert.equal(fresh.shop.connectWebhookSecret, 'whsec_connect')
assert.deepEqual(
[fresh.collections.shopConfig, fresh.collections.shopProducts, fresh.collections.shopOrders, fresh.collections.shopWiderrufe],
['sConfig', 'sProducts', 'sOrders', 'sWiderrufe'],
)
})
test('Konfiguration: leerer oder unbekannter Schalter ist off', async () => {
for (const value of ['', ' ', 'banane', 'true', '1', 'ON ']) {
const fresh = await freshConfig({ SHOP_ENABLED: value })
assert.equal(fresh.shop.mode, value.trim().toLowerCase() === 'on' ? 'on' : 'off', `SHOP_ENABLED=${JSON.stringify(value)}`)
}
})
test('Konfiguration: Katalogeintrag shopSetup', () => {
assert.deepEqual(config.shopSetup, {
key: 'shopSetup',
name: 'Webshop – Einrichtung',
amountCents: 29900,
lookupKey: 'webklar_shop_einrichtung_29900',
oneTime: true,
priceNickname: 'Webshop-Einrichtung 299 €',
})
})
test('shopModus: leer und unbekannt sind off, pilot und on bleiben', () => {
for (const value of [undefined, null, '', 'banane', 'PILOT', 'true']) {
config.shop.mode = value
assert.equal(shopModus(), 'off', `mode=${JSON.stringify(value)}`)
}
config.shop.mode = 'pilot'
assert.equal(shopModus(), 'pilot')
config.shop.mode = 'on'
assert.equal(shopModus(), 'on')
})
test('shopModus: Aenderung von config.shop.mode wirkt sofort', () => {
config.shop.mode = 'off'
assert.equal(shopModus(), 'off')
config.shop.mode = 'on'
assert.equal(shopModus(), 'on')
config.shop.mode = 'off'
assert.equal(shopModus(), 'off')
})
test('shopFuerKunde: off sperrt alle, auch mit Stern in der Liste', () => {
config.shop.mode = 'off'
config.shop.pilotEmails = ['*', 'kenso.gri@gmail.com']
assert.equal(shopFuerKunde('kenso.gri@gmail.com'), false)
assert.equal(shopFuerKunde('jemand@example.com'), false)
})
test('shopFuerKunde: pilot erlaubt nur Adressen der Liste, Schreibweise egal', () => {
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['kenso.gri@gmail.com']
assert.equal(shopFuerKunde('kenso.gri@gmail.com'), true)
assert.equal(shopFuerKunde(' Kenso.Gri@Gmail.com '), true)
assert.equal(shopFuerKunde('jemand@example.com'), false)
assert.equal(shopFuerKunde(''), false)
assert.equal(shopFuerKunde(undefined), false)
assert.equal(shopFuerKunde(null), false)
})
test('shopFuerKunde: pilot mit leerer Liste laesst niemanden durch, Stern alle (auch ohne Adresse)', () => {
config.shop.mode = 'pilot'
config.shop.pilotEmails = []
assert.equal(shopFuerKunde('kenso.gri@gmail.com'), false)
assert.equal(shopFuerKunde(''), false)
config.shop.pilotEmails = ['*']
assert.equal(shopFuerKunde('jemand@example.com'), true)
assert.equal(shopFuerKunde(''), true)
})
test('shopFuerKunde: on erlaubt alle Kunden, die Liste spielt keine Rolle', () => {
config.shop.mode = 'on'
config.shop.pilotEmails = []
assert.equal(shopFuerKunde('jemand@example.com'), true)
assert.equal(shopFuerKunde(''), true)
})
test('shopFuerKunde: Unbekannter Modus gilt als off', () => {
config.shop.mode = 'banane'
config.shop.pilotEmails = ['*']
assert.equal(shopFuerKunde('kenso.gri@gmail.com'), false)
})
+81
View File
@@ -0,0 +1,81 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { useTestEnv } from './support/testEnv.js'
// Task H2: oeffentliche Shop-Seiten /shop/:siteKey und /shop/:siteKey/widerruf
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { startTestApp } = await import('./support/testApp.js')
const { shopSeiteRouter, resetShopSeiteForTests } = await import('../server/routes/shopSeite.js')
resetShopSeiteForTests()
const app = await startTestApp({ '/': shopSeiteRouter() })
after(() => app.close())
const KEY = 'abcdefghijklmnop'.replace(/[ilo]/g, 'a') // nur a-z und 2-7 wie siteKeys
const get = async (path) => {
const r = await fetch(app.url + path)
return { status: r.status, headers: r.headers, text: await r.text() }
}
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
test('gueltiger Schluessel: 200 HTML mit Schluessel im Container und den Schutz-Headern', async () => {
const r = await get(`/shop/${KEY}`)
assert.equal(r.status, 200)
assert.match(r.headers.get('content-type'), /^text\/html; charset=utf-8/)
assert.match(r.text, new RegExp(`<div data-webklar-shop="${KEY}" data-ansicht="" data-api="">`))
assert.doesNotMatch(r.text, /__SITEKEY__|__ANSICHT__/)
assert.match(r.text, /<script src="\/embed\/shop\.js" defer><\/script>/)
assert.equal(r.headers.get('cache-control'), 'no-store')
assert.equal(r.headers.get('x-content-type-options'), 'nosniff')
assert.equal(r.headers.get('referrer-policy'), 'strict-origin-when-cross-origin')
assert.equal(r.headers.get('x-robots-tag'), 'noindex')
const csp = r.headers.get('content-security-policy')
for (const d of ["default-src 'self'", "script-src 'self'", "connect-src 'self'", "img-src 'self' data:", "frame-ancestors 'none'", "base-uri 'none'", "form-action 'self'"]) {
assert.ok(csp.includes(d), d)
}
assert.ok(!/script-src[^;]*unsafe/.test(csp), 'keine unsicheren Skripte')
})
test('/widerruf setzt data-ansicht="widerruf"', async () => {
const r = await get(`/shop/${KEY}/widerruf`)
assert.equal(r.status, 200)
assert.match(r.text, new RegExp(`data-webklar-shop="${KEY}" data-ansicht="widerruf"`))
assert.equal(r.headers.get('x-robots-tag'), 'noindex')
})
test('jeder syntaktisch gueltige Schluessel liefert dieselbe Seite (kein Orakel)', async () => {
const a = await get(`/shop/${KEY}`)
const b = await get('/shop/2222222222222222')
assert.equal(b.status, 200)
assert.equal(a.text.replaceAll(KEY, 'X'), b.text.replaceAll('2222222222222222', 'X'))
})
test('ungueltige Schluessel und fremde Pfade: 404', async () => {
for (const p of ['/shop/xyz', `/shop/${KEY.toUpperCase()}`, `/shop/${KEY}a`, '/shop/abcdefghijklmn01', '/shop/xyz/abc', `/shop/${KEY}/agb`, `/shop/${KEY}/widerruf/x`, '/shop/%3Cscript%3E', '/shop', '/shop/']) {
const r = await get(p)
assert.equal(r.status, 404, p)
assert.doesNotMatch(r.text, /data-webklar-shop/, p)
}
})
test('nur GET', async () => {
const r = await fetch(`${app.url}/shop/${KEY}`, { method: 'POST' })
assert.equal(r.status, 404)
})
test('shop-seite.html: kein Inline-Skript, nur das Widget; in server/index.js vor express.static gemountet', () => {
const html = lies('public/shop-seite.html')
const skripte = [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)]
assert.equal(skripte.length, 1)
assert.match(skripte[0][1], /\bsrc="\/embed\/shop\.js"/)
assert.equal(skripte[0][2].trim(), '')
assert.doesNotMatch(html, /\son[a-z]+\s*=/i, 'keine Inline-Handler')
assert.doesNotMatch(html, /javascript:/i)
assert.match(html, /<html lang="de">/)
assert.match(html, /<title>Shop<\/title>/)
assert.match(html, /Der Shop wird geladen …/)
const index = lies('server/index.js')
const mount = index.indexOf('app.use(shopSeiteRouter())')
assert.ok(mount > 0 && mount < index.indexOf('app.use(express.static(publicDir))'))
})
+293
View File
@@ -0,0 +1,293 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_COLLECTIONS, SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Das Skript und die Konfiguration laden erst nach useTestEnv (Endpunkt des Nachbaus), daher dynamische Imports
const aw = await startAppwriteMock({ schema: { shopConfig: schemaKeys('shopConfig') }, datetimes: SHOP_DATETIMES })
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { main } = await import('../scripts/shop-setup.mjs')
const { createDocument, updateDocument, getDocument } = await import('../server/services/appwriteAdmin.js')
after(() => Promise.all([aw.close(), st.close()]))
const NAMES = ['shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'shopRechtstexteArchiv']
const keysOf = (name) => SHOP_COLLECTIONS[name].attributes.map((a) => a.key)
/**
* Appwrite-Client mit Zustand: Attribute gibt es nur in vorhandenen Collections, Indizes nur auf vorhandenen
* Attributen (wie Appwrite). `initial` = { collectionId: { attrs: [...], indexes: [...] } }.
*/
function fakeClient(initial = {}) {
const state = new Map(
Object.entries(initial).map(([id, c]) => [id, { attrs: new Map((c.attrs || []).map((key) => [key, null])), indexes: new Set(c.indexes || []) }]),
)
const calls = { collections: [], attrs: [], indexes: [] }
const need = (id) => {
if (!state.has(id)) throw Object.assign(new Error(`Collection ${id} fehlt`), { status: 404 })
return state.get(id)
}
return {
state,
calls,
async collectionExists(id) {
return state.has(id)
},
async ensureCollection(id, name) {
if (state.has(id)) return false
state.set(id, { attrs: new Map(), indexes: new Set() })
calls.collections.push({ id, name })
return true
},
async listAttributes(id) {
return [...need(id).attrs.keys()].map((key) => ({ key }))
},
async createAttribute(id, attr) {
const collection = need(id)
if (collection.attrs.has(attr.key)) throw new Error(`Attribut ${id}.${attr.key} existiert schon`)
collection.attrs.set(attr.key, attr)
calls.attrs.push(`${id}.${attr.key}`)
},
async waitForAttribute(id, key) {
assert.ok(need(id).attrs.has(key), `wartet auf nie angelegtes Attribut ${id}.${key}`)
},
async listIndexes(id) {
return [...need(id).indexes].map((key) => ({ key }))
},
async createIndex(id, index) {
const collection = need(id)
for (const key of index.attributes) {
if (!collection.attrs.has(key)) throw new Error(`Index ${index.key}: Attribut ${key} fehlt in ${id}`)
}
collection.indexes.add(index.key)
calls.indexes.push(`${id}.${index.key}`)
},
}
}
const quiet = () => {}
const expectedAttrs = NAMES.flatMap((name) => keysOf(name).map((key) => `${config.collections[name]}.${key}`))
const expectedIndexes = NAMES.flatMap((name) => SHOP_COLLECTIONS[name].indexes.map((i) => `${config.collections[name]}.${i.key}`))
test('Schema: Attribute je Collection wie in der Spezifikation', () => {
assert.deepEqual(Object.keys(SHOP_COLLECTIONS), NAMES)
assert.deepEqual(keysOf('shopConfig'), [
'customerId', 'projectId', 'siteKey', 'anfrage', 'anfrageStatus', 'anfrageAt', 'anfrageEntschiedenAt',
'anfrageEntschiedenVon', 'anfrageBegruendung', 'stripeAccountId', 'stripeStatus', 'einstellungen', 'rechtstexte',
'rechtstexteBestaetigtAt', 'rechtstexteBestaetigtVon', 'rechtstexteHash', 'geoeffnet', 'geoeffnetAt', 'version',
])
assert.deepEqual(keysOf('shopProducts'), ['shopId', 'customerId', 'aktiv', 'sort', 'name', 'produkt'])
assert.deepEqual(keysOf('shopOrders'), [
'shopId', 'customerId', 'bestellnummer', 'status', 'tokenHash', 'stripeSessionId', 'paymentIntentId', 'summeCent',
'erstelltAt', 'ablaufAt', 'bezahltAt', 'kaeuferEmail', 'bestellung', 'anonymisiertAt',
])
assert.deepEqual(keysOf('shopWiderrufe'), [
'shopId', 'customerId', 'bestellnummer', 'name', 'email', 'nachricht', 'eingangAt', 'bestaetigungAt', 'bearbeitetAt', 'orderId',
])
})
test('Schema: Typen und Groessen', () => {
const byKey = (name) => Object.fromEntries(SHOP_COLLECTIONS[name].attributes.map((a) => [a.key, a]))
const shop = byKey('shopConfig')
const shopSizes = {
customerId: 36, projectId: 36, siteKey: 16, anfrage: 4000, anfrageStatus: 16, anfrageEntschiedenVon: 255,
anfrageBegruendung: 1000, stripeAccountId: 64, stripeStatus: 4000, einstellungen: 20000, rechtstexte: 100000,
rechtstexteBestaetigtVon: 255, rechtstexteHash: 64,
}
for (const [key, size] of Object.entries(shopSizes)) {
assert.deepEqual([shop[key].type, shop[key].size], ['string', size], `shopConfig.${key}`)
}
assert.equal(shop.geoeffnet.type, 'boolean')
assert.equal(shop.version.type, 'integer')
for (const key of ['anfrageAt', 'anfrageEntschiedenAt', 'rechtstexteBestaetigtAt', 'geoeffnetAt']) assert.equal(shop[key].type, 'datetime', key)
const products = byKey('shopProducts')
assert.deepEqual([products.shopId.size, products.customerId.size, products.name.size, products.produkt.size], [36, 36, 120, 30000])
assert.equal(products.aktiv.type, 'boolean')
assert.equal(products.sort.type, 'integer')
const orders = byKey('shopOrders')
const orderSizes = {
shopId: 36, customerId: 36, bestellnummer: 16, status: 16, tokenHash: 64, stripeSessionId: 128, paymentIntentId: 64,
kaeuferEmail: 320, bestellung: 60000,
}
for (const [key, size] of Object.entries(orderSizes)) {
assert.deepEqual([orders[key].type, orders[key].size], ['string', size], `shopOrders.${key}`)
}
assert.equal(orders.summeCent.type, 'integer')
for (const key of ['erstelltAt', 'ablaufAt', 'bezahltAt', 'anonymisiertAt']) assert.equal(orders[key].type, 'datetime', key)
const widerrufe = byKey('shopWiderrufe')
assert.deepEqual(
[widerrufe.shopId.size, widerrufe.customerId.size, widerrufe.bestellnummer.size, widerrufe.name.size, widerrufe.email.size, widerrufe.nachricht.size, widerrufe.orderId.size],
[36, 36, 32, 200, 320, 2000, 36],
)
for (const key of ['eingangAt', 'bestaetigungAt', 'bearbeitetAt']) assert.equal(widerrufe[key].type, 'datetime', key)
// I-6: Archiv der bestaetigten Rechtstexte
assert.deepEqual(keysOf('shopRechtstexteArchiv'), ['shopId', 'customerId', 'hash', 'texte', 'bestaetigtAt', 'bestaetigtVon'])
const archiv = byKey('shopRechtstexteArchiv')
assert.deepEqual([archiv.shopId.size, archiv.customerId.size, archiv.hash.size, archiv.texte.size, archiv.bestaetigtVon.size], [36, 36, 64, 100000, 255])
assert.equal(archiv.bestaetigtAt.type, 'datetime')
})
test('Schema: jedes Attribut ist optional, hat eine gueltige Form und einen gueltigen Schluessel (kein Feld data)', () => {
for (const name of NAMES) {
const { attributes } = SHOP_COLLECTIONS[name]
assert.equal(new Set(attributes.map((a) => a.key)).size, attributes.length, `${name}: doppelte Schluessel`)
for (const attr of attributes) {
assert.match(attr.key, /^[A-Za-z][A-Za-z0-9]{0,35}$/, `${name}.${attr.key}`)
assert.notEqual(attr.key, 'data', `${name}: Feld data ist verboten`)
assert.equal(attr.required, false, `${name}.${attr.key}`)
assert.ok(['string', 'integer', 'boolean', 'datetime'].includes(attr.type), `${name}.${attr.key}: Typ ${attr.type}`)
if (attr.type === 'string') assert.ok(Number.isInteger(attr.size) && attr.size > 0, `${name}.${attr.key}: Groesse`)
else assert.equal('size' in attr, false, `${name}.${attr.key}: Groesse nur bei Text`)
}
}
})
test('Schema: Indizes wie in der Spezifikation und nur auf vorhandenen Attributen', () => {
const indexes = (name) => SHOP_COLLECTIONS[name].indexes.map((i) => [i.key, ...i.attributes])
assert.deepEqual(indexes('shopConfig'), [
['idx_customer', 'customerId'],
['idx_sitekey', 'siteKey'],
['idx_anfrage', 'anfrageStatus'],
['idx_account', 'stripeAccountId'],
])
assert.deepEqual(indexes('shopProducts'), [['idx_shop', 'shopId', 'sort']])
assert.deepEqual(indexes('shopOrders'), [
['idx_shop_status', 'shopId', 'status'],
['idx_nummer', 'shopId', 'bestellnummer'],
['idx_session', 'stripeSessionId'],
['idx_erstellt', 'shopId', 'erstelltAt'],
])
assert.deepEqual(indexes('shopWiderrufe'), [['idx_shop', 'shopId', 'eingangAt']])
assert.deepEqual(indexes('shopRechtstexteArchiv'), [['idx_shop_hash', 'shopId', 'hash']])
for (const name of NAMES) {
for (const index of SHOP_COLLECTIONS[name].indexes) {
assert.equal(index.type, 'key')
assert.ok(index.attributes.every((key) => keysOf(name).includes(key)), `${name}.${index.key}`)
}
}
})
test('Schema: SHOP_DATETIMES nennt je Collection genau die datetime-Attribute', () => {
assert.deepEqual(SHOP_DATETIMES, {
shopConfig: ['anfrageAt', 'anfrageEntschiedenAt', 'rechtstexteBestaetigtAt', 'geoeffnetAt'],
shopProducts: [],
shopOrders: ['erstelltAt', 'ablaufAt', 'bezahltAt', 'anonymisiertAt'],
shopWiderrufe: ['eingangAt', 'bestaetigungAt', 'bearbeitetAt'],
shopRechtstexteArchiv: ['bestaetigtAt'],
})
})
test('schemaKeys kennt die Shop-Collections; bestehende Setup-Skripte funktionieren weiter', () => {
for (const name of NAMES) assert.deepEqual(schemaKeys(name), keysOf(name))
assert.ok(schemaKeys('shopConfig').includes('einstellungen'))
assert.ok(!schemaKeys('shopConfig').includes('data'))
assert.ok(schemaKeys('termineConfig').includes('einstellung'))
assert.ok(schemaKeys('subscriptions').length > 0)
assert.throws(() => schemaKeys('gibtEsNicht'), /fehlt im Setup-Skript/)
})
test('Rundreise: Shop-Dokument mit Datum und JSON-Text anlegen, aendern und zuruecklesen', async () => {
const collection = config.collections.shopConfig
const einstellungen = JSON.stringify({ schema: 1, betrieb: { name: 'Blumen Müller' } })
const created = await createDocument(
collection,
{ customerId: 'c1', projectId: 'p1', siteKey: 'abc123', anfrageStatus: 'offen', anfrageAt: '2026-10-05T10:00:00.000Z', einstellungen, geoeffnet: false, version: 1 },
'feature-1',
)
assert.equal(created.$id, 'feature-1')
await updateDocument(collection, 'feature-1', { anfrageStatus: 'angenommen', geoeffnet: true, version: 2 })
const doc = await getDocument(collection, 'feature-1')
assert.equal(doc.anfrageStatus, 'angenommen')
assert.equal(doc.geoeffnet, true)
assert.equal(doc.version, 2)
assert.equal(doc.customerId, 'c1')
assert.equal(JSON.parse(doc.einstellungen).betrieb.name, 'Blumen Müller')
})
test('Rundreise: unbekanntes Attribut lehnt der Nachbau ab wie Appwrite (Schema-Pruefung greift)', async () => {
await assert.rejects(
() => createDocument(config.collections.shopConfig, { customerId: 'c1', fremd: 'x' }, 'feature-2'),
/Unknown attribute/,
)
})
test('Konfiguration: Collection-IDs der Shop-Collections', () => {
assert.deepEqual(NAMES.map((name) => config.collections[name]), NAMES)
})
test('main legt auf leerer Datenbank 5 Collections, alle Attribute und Indizes an', async () => {
const client = fakeClient()
const result = await main({ client, log: quiet })
assert.deepEqual(client.calls.collections, NAMES.map((id) => ({ id, name: id })))
assert.deepEqual([...client.calls.attrs].sort(), [...expectedAttrs].sort())
assert.deepEqual([...client.calls.indexes].sort(), [...expectedIndexes].sort())
assert.equal(result.missing.length, expectedAttrs.length)
assert.deepEqual(result.missingCollections, NAMES)
assert.equal(result.missingIndexes.length, expectedIndexes.length)
const orders = client.state.get('shopOrders').attrs
assert.deepEqual(orders.get('kaeuferEmail'), { key: 'kaeuferEmail', type: 'string', size: 320, required: false })
assert.deepEqual(orders.get('erstelltAt'), { key: 'erstelltAt', type: 'datetime', required: false })
assert.deepEqual(orders.get('summeCent'), { key: 'summeCent', type: 'integer', required: false })
assert.deepEqual(client.state.get('shopConfig').attrs.get('geoeffnet'), { key: 'geoeffnet', type: 'boolean', required: false })
})
test('main: zweiter Lauf legt nichts mehr an', async () => {
const client = fakeClient()
await main({ client, log: quiet })
client.calls.collections.length = client.calls.attrs.length = client.calls.indexes.length = 0
const result = await main({ client, log: quiet })
assert.deepEqual(client.calls, { collections: [], attrs: [], indexes: [] })
assert.deepEqual(result, { missing: [], missingCollections: [], missingIndexes: [] })
})
test('main: legt nur fehlende Attribute und Indizes einer vorhandenen Collection an', async () => {
const client = fakeClient({
shopWiderrufe: { attrs: ['shopId', 'customerId', 'bestellnummer', 'name', 'email', 'nachricht', 'eingangAt', 'bestaetigungAt'], indexes: [] },
})
await main({ client, log: quiet })
assert.ok(!client.calls.collections.some((c) => c.id === 'shopWiderrufe'))
assert.deepEqual(client.calls.attrs.filter((k) => k.startsWith('shopWiderrufe.')), ['shopWiderrufe.bearbeitetAt', 'shopWiderrufe.orderId'])
assert.deepEqual(client.calls.indexes.filter((k) => k.startsWith('shopWiderrufe.')), ['shopWiderrufe.idx_shop'])
assert.equal(client.calls.collections.length, 4)
})
test('main --check legt nichts an und meldet Fehlendes', async () => {
const client = fakeClient()
const lines = []
const result = await main({ check: true, client, log: (line) => lines.push(line) })
assert.deepEqual(client.calls, { collections: [], attrs: [], indexes: [] })
assert.equal(client.state.size, 0)
assert.deepEqual(result.missingCollections, NAMES)
assert.equal(result.missing.length, expectedAttrs.length)
assert.equal(result.missingIndexes.length, expectedIndexes.length)
assert.ok(lines.some((line) => line.includes('shopOrders') && line.includes('FEHLT')))
})
test('main --check meldet bei vollstaendigem Stand nichts', async () => {
const client = fakeClient()
await main({ client, log: quiet })
const result = await main({ check: true, client, log: quiet })
assert.deepEqual(result, { missing: [], missingCollections: [], missingIndexes: [] })
})
test('main --check meldet ein einzelnes fehlendes Attribut und einen fehlenden Index', async () => {
const client = fakeClient()
await main({ client, log: quiet })
client.state.get('shopOrders').attrs.delete('anonymisiertAt')
client.state.get('shopConfig').indexes.delete('idx_account')
const lines = []
const result = await main({ check: true, client, log: (line) => lines.push(line) })
assert.deepEqual(result.missing.map((a) => `${a.collection}.${a.key}`), ['shopOrders.anonymisiertAt'])
assert.deepEqual(result.missingIndexes.map((i) => `${i.collection}.${i.key}`), ['shopConfig.idx_account'])
assert.deepEqual(result.missingCollections, [])
assert.ok(lines.some((line) => line.includes('shopOrders.anonymisiertAt FEHLT')))
assert.ok(lines.some((line) => line.includes('Index shopConfig.idx_account FEHLT')))
})
+245
View File
@@ -0,0 +1,245 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Webshop D2: Stripe-Konto einrichten, neuer Onboarding-Link, Status aktualisieren (Fake-Stripe)
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
customerViewLog: schemaKeys('customerViewLog'),
},
datetimes: SHOP_DATETIMES,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js')
const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const sf = await import('../server/services/siteFeatures.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { leereCache } = await import('../server/services/shop/kontext.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' })
// Je Test ein frisches Projekt: Kontoanlage ist je Projekt idempotent (Idempotency-Key), alte Konten wuerden sonst wiederkehren
let P = 'pt1'
let zaehler = 0
const cookie = customerCookie('c1')
const GEDULD = 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.'
const post = (pfad, body = { projectId: P }, opts = { cookie }) => send('POST', `/api/shop/stripe/${pfad}`, { ...opts, body })
const status = (projectId = P) => send('GET', `/api/shop/status?projectId=${projectId}`, { cookie })
const plattform = () => st.calls.filter((c) => c.path === '/v1/accounts' || c.path === '/v1/account_links' || c.path.startsWith('/v1/accounts/'))
async function aktiviere(projectId = P, state = 'active') {
const feature = await sf.ensureFeatureDoc('c1', projectId, 'shop')
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields(state))
return sf.getFeatureDoc('c1', projectId, 'shop')
}
const kontenFuer = (projectId) => [...st.accounts.values()].filter((a) => a.metadata?.projectId === projectId)
const shopConfigDoc = (feature) => aw.docs('shopConfig').get(feature.$id)
beforeEach(async () => {
P = `pn${(zaehler += 1)}`
aw.seed('websiteProjects', { $id: P, customerId: 'c1', projectName: 'Töpferei', liveDomain: 'toepferei.example', archived: false })
config.shop.mode = 'pilot'
config.shop.pilotEmails = [customers.c1.email]
resetShopRoutesForTests()
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'customerViewLog']) {
for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id)
}
st.calls.length = 0
leereCache()
})
test('einrichten: legt ein Konto an, speichert Konto-ID und Status, liefert den Onboarding-Link mit Rücksprung-Adressen', async () => {
const feature = await aktiviere()
const res = await post('einrichten')
assert.equal(res.status, 200)
assert.match(res.body.url, /^https:\/\/connect\.stripe\.test\/setup\/acct_/)
assert.deepEqual(Object.keys(res.body), ['url'])
const doc = shopConfigDoc(feature)
assert.match(doc.stripeAccountId, /^acct_/)
assert.equal(kontenFuer(P).length, 1)
const gespeichert = JSON.parse(doc.stripeStatus)
assert.equal(gespeichert.charges_enabled, false)
assert.deepEqual(gespeichert.currently_due, ['external_account'])
// Plattform-Aufrufe ohne Stripe-Account-Kopfzeile, Konto mit Kunden-E-Mail und Projekt in den Metadaten
assert.ok(plattform().length >= 3)
for (const c of plattform()) assert.equal(c.account, undefined, `${c.method} ${c.path} darf keine Stripe-Account-Kopfzeile tragen`)
const anlage = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/accounts')
assert.equal(anlage.body.email, customers.c1.email)
assert.equal(anlage.body['metadata[projectId]'], P)
const link = st.calls.find((c) => c.path === '/v1/account_links')
assert.equal(link.body.account, doc.stripeAccountId)
assert.equal(link.body.return_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${P}`)
assert.equal(link.body.refresh_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=neu&projekt=${P}`)
// GET /status zeigt das Konto als verbunden, ohne die Konto-ID
const s = (await status()).body
assert.equal(s.stripe.verbunden, true)
assert.equal(JSON.stringify(s).includes(doc.stripeAccountId), false)
})
test('einrichten: zwei parallele Aufrufe (Doppelklick) legen nur ein Konto an', async () => {
const feature = await aktiviere(P, 'setup')
const [a, b] = await Promise.all([post('einrichten'), post('einrichten')])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/accounts').length, 1)
assert.equal(kontenFuer(P).length, 1)
assert.equal(st.calls.filter((c) => c.path === '/v1/account_links').length, 2)
assert.ok(a.body.url && b.body.url)
// weiterer Aufruf: Konto bleibt dasselbe
const id = shopConfigDoc(feature).stripeAccountId
assert.equal((await post('einrichten')).status, 200)
assert.equal(shopConfigDoc(feature).stripeAccountId, id)
assert.equal(kontenFuer(P).length, 1)
})
test('link: braucht ein Konto (409), liefert danach einen neuen Link mit den Rücksprung-Adressen', async () => {
await aktiviere()
const ohne = await post('link')
assert.deepEqual([ohne.status, ohne.body.error, ohne.body.code], [409, 'Es ist noch kein Stripe-Konto angelegt.', 'kein_konto'])
assert.equal((await post('einrichten')).status, 200)
st.calls.length = 0
const res = await post('link')
assert.equal(res.status, 200)
assert.match(res.body.url, /^https:\/\/connect\.stripe\.test\/setup\//)
assert.equal(st.calls.length, 1)
assert.equal(st.calls[0].account, undefined)
assert.equal(st.calls[0].body.return_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${P}`)
assert.equal(kontenFuer(P).length, 1)
})
test('aktualisieren: braucht ein Konto (409), liest den Status neu und speichert ihn', async () => {
const feature = await aktiviere()
assert.equal((await post('aktualisieren')).status, 409)
await post('einrichten')
const id = shopConfigDoc(feature).stripeAccountId
st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
const res = await post('aktualisieren')
assert.equal(res.status, 200)
assert.equal(res.body.stripe.verbunden, true)
assert.equal(res.body.stripe.status.charges_enabled, true)
assert.deepEqual(res.body.stripe.status.currently_due, [])
assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).charges_enabled, true)
assert.equal(JSON.stringify(res.body).includes(id), false)
// gleiche Form wie GET /status
assert.deepEqual((await status()).body.stripe, res.body.stripe)
})
test('aktualisieren: Hinweis zu Klarna/SEPA bleibt nur, solange Zahlungen möglich sind und eine der Zahlungsarten gewählt ist', async () => {
const feature = await aktiviere()
await post('einrichten')
const id = shopConfigDoc(feature).stripeAccountId
const setze = async (stripeStatus, zahlungsarten) => {
const e = { zahlungsarten }
await updateDocument(config.collections.shopConfig, feature.$id, { stripeStatus: JSON.stringify(stripeStatus), einstellungen: JSON.stringify(e) })
leereCache()
}
st.setAccount(id, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card', 'klarna'])
await post('aktualisieren')
assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, 'Klarna ist noch nicht freigeschaltet.')
await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card'])
await post('aktualisieren')
assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, undefined)
st.setAccount(id, { charges_enabled: false })
await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card', 'sepa'])
await post('aktualisieren')
assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, undefined)
})
test('Stripe-Fehler: 502 mit festem Du-Text, nie der Stripe-Text; bei gescheiterter Kontoanlage bleibt kein Konto gespeichert', async () => {
const feature = await aktiviere()
const log = []
const orig = console.error
console.error = (...a) => log.push(a.join(' '))
try {
st.failNext({ method: 'POST', path: '/v1/accounts', status: 400, message: 'Geheimer Stripe-Text sk_live_123' })
const a = await post('einrichten')
assert.deepEqual([a.status, a.body.error], [502, GEDULD])
assert.equal(JSON.stringify(a.body).includes('Geheim'), false)
assert.equal(shopConfigDoc(feature).stripeAccountId || '', '')
// Konto da, Link scheitert: Konto bleibt gespeichert, 502
st.failNext({ method: 'POST', path: '/v1/account_links', status: 400, message: 'Geheimer Linktext' })
const b = await post('einrichten')
assert.deepEqual([b.status, b.body.error], [502, GEDULD])
assert.match(shopConfigDoc(feature).stripeAccountId, /^acct_/)
st.failNext({ method: 'POST', path: '/v1/account_links', status: 400, message: 'Geheimer Linktext' })
assert.deepEqual([(await post('link')).status], [502])
st.failNext({ method: 'GET', path: /^\/v1\/accounts\//, status: 400, message: 'Geheimer Lesetext' })
const c = await post('aktualisieren')
assert.deepEqual([c.status, c.body.error], [502, GEDULD])
} finally {
console.error = orig
}
assert.equal(log.some((l) => l.includes('Geheim') || l.includes('sk_live')), false)
})
test('Kundenansicht: der Wächter sperrt die Routen (nicht auf der Erlaubnisliste), der Router selbst auch ohne Wächter (Kundenansicht und Ansehen als)', async () => {
const feature = await aktiviere()
for (const pfad of ['einrichten', 'link', 'aktualisieren']) {
const res = await post(pfad, { projectId: P }, { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } })
assert.deepEqual([res.status, res.body.code], [403, 'view_blocked'], pfad)
}
const express = (await import('express')).default
const { sessionMiddleware } = await import('../server/middleware/session.js')
const roh = express()
roh.use(sessionMiddleware())
roh.use(express.json())
roh.use('/api/shop', shopRoutes)
const server = await new Promise((resolve) => {
const s = roh.listen(0, '127.0.0.1', () => resolve(s))
})
try {
const url = `http://127.0.0.1:${server.address().port}`
for (const pfad of ['einrichten', 'link', 'aktualisieren']) {
for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) {
const res = await fetch(`${url}/api/shop/stripe/${pfad}`, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify({ projectId: P }) })
assert.equal(res.status, 403, pfad)
assert.equal((await res.json()).error, 'In der Kundenansicht kann das Stripe-Konto nicht eingerichtet werden.')
}
}
} finally {
await new Promise((resolve) => server.close(resolve))
}
assert.equal(st.calls.length, 0)
assert.equal(shopConfigDoc(feature)?.stripeAccountId || '', '')
})
test('Zugang und Zustand: fremdes Projekt 403, ohne Projekt 400, ohne Session 401, nicht eingerichtet/deaktiviert/Schalter aus 409', async () => {
const feature = await aktiviere()
assert.equal((await post('einrichten', { projectId: 'pt3' })).status, 403)
assert.equal((await post('einrichten', {})).status, 400)
assert.equal((await send('POST', '/api/shop/stripe/einrichten', { body: { projectId: P } })).status, 401)
assert.equal((await post('einrichten', { projectId: 'pt2' })).status, 409)
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('disabled'))
const aus = await post('einrichten')
assert.deepEqual([aus.status, aus.body.code], [409, 'nicht_eingerichtet'])
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('off'))
assert.equal((await post('aktualisieren')).status, 409)
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('active'))
config.shop.mode = 'off'
const schalter = await post('link')
assert.deepEqual([schalter.status, schalter.body.code], [409, 'nicht_verfuegbar'])
assert.equal(st.calls.length, 0)
})
test('Rate-Limit: die 301. Schreibanfrage in der Stunde wird abgewiesen', async () => {
await aktiviere()
let letzter
for (let i = 0; i < 301; i += 1) letzter = await post('link')
assert.equal(letzter.status, 429)
})
+615
View File
@@ -0,0 +1,615 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { euroText, h, request } from '../public/shop-dom.js'
import { render } from '../public/shop-tab-artikel.js'
// Task G4: Reiter „Artikel“ (public/shop-tab-artikel.js) mit Fake-DOM und Fake-fetch: Liste, Aktiv-Schalter, Sortierung, Editor (Anlegen, Varianten,
// Bilder mit rohem Upload, Feldfehler am richtigen Feld), Loeschen mit Rueckfrage, nurLesen. Die Server-Antworten sind von Hand gebaut
// (Form: server/routes/shop.js produktAnsicht, server/services/shop/produkte.js).
const P = 'p1'
const vorschau = (id) => ({ id, klein: `/api/shop/bild/${P}/${id}-s.webp`, gross: `/api/shop/bild/${P}/${id}-l.webp` })
/** Artikel wie ihn GET /api/shop/produkte liefert (alle gespeicherten Felder plus id, sort, bildUrls). */
const artikel = (id, name, extra = {}) => {
const a = {
id,
name,
kategorie: '',
kurz: '',
beschreibung: '',
preisCent: 1250,
steuersatz: 19,
grundpreis: null,
bestand: null,
aktiv: true,
sort: 0,
varianten: null,
bilder: [],
lieferart: 'beides',
widerruf: 'gesetzlich',
sicherheit: { hersteller: 'selbst', euVerantwortlicher: null, hinweise: '' },
lebensmittel: { aktiv: false, pflichtangaben: '' },
...extra,
}
a.bildUrls = a.bilder.map(vorschau)
return a
}
/** Wie koerper, dazu der beim Laden gesehene Bestand (bestandGeladen, je Variantenwert mit id) - so geht ein bestehender Artikel zum Server. */
const geladenKoerper = (a, extra = {}) => {
const k = koerper(a)
k.bestandGeladen = a.bestand ?? null
if (k.varianten) k.varianten = { ...k.varianten, werte: k.varianten.werte.map((w) => ({ ...w, bestandGeladen: w.bestand ?? null })) }
return { ...k, ...extra }
}
/** Der Artikel so, wie er beim Speichern zum Server geht (ohne id, sort, bildUrls). */
const koerper = (a, extra = {}) => {
const { id, sort, bildUrls, ...rest } = a
void id; void sort; void bildUrls
return { ...rest, ...extra }
}
const LEER_KOERPER = {
name: '', kategorie: '', kurz: '', beschreibung: '', preisCent: null, steuersatz: 19, grundpreis: null, bestand: null, aktiv: true, varianten: null, bilder: [],
lieferart: 'beides', widerruf: 'gesetzlich', sicherheit: { hersteller: 'selbst', euVerantwortlicher: null, hinweise: '' }, lebensmittel: { aktiv: false, pflichtangaben: '' },
}
/**
* Fake-Server: GET produkte und einstellungen aus den Parametern, alles andere aus sonst(aufruf) (Standard 200 {}).
* Der Upload (POST /api/shop/bild, rohe Bytes) laeuft am Fake-fetch vorbei: Aufrufe stehen in uploads, die Antwort kommt aus upload(aufruf)
* (Objekt oder Promise davon; Standard 201 { imageId: 'neu1' }), weil fakeDom nur JSON-Koerper kennt.
*/
function starte({ produkte = [], einstellungen = { kleinunternehmer: false }, nurLesen = false, sonst = () => null, upload, maxBilder = 4 } = {}) {
const route = (aufruf) => {
if (aufruf.method === 'GET' && aufruf.url === `/api/shop/produkte?projectId=${P}`) return { body: { produkte, maxArtikel: 500, maxBilder } }
if (aufruf.method === 'GET' && aufruf.url === `/api/shop/einstellungen?projectId=${P}`) return { body: { einstellungen, version: 1, vollstaendig: true } }
return sonst(aufruf) || { body: {} }
}
const fx = installFakeDom({ antworten: Array.from({ length: 80 }, () => route) })
const uploads = []
const echterFetch = globalThis.fetch
let zaehler = 0
globalThis.fetch = async (url, optionen = {}) => {
if (optionen.headers?.['Content-Type'] !== 'application/octet-stream') return echterFetch(url, optionen)
const aufruf = { method: optionen.method, url: String(url), datei: optionen.body, credentials: optionen.credentials }
uploads.push(aufruf)
zaehler += 1
const antwort = await (upload ? upload(aufruf, zaehler) : { status: 201, body: { imageId: `neu${zaehler}` } })
const { status = 201, body = {} } = antwort
return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) }
}
const meldungen = []
const panel = h('div', { class: 'sh-panel' })
fx.document.body.append(panel)
const ctx = { projectId: P, nurLesen, aktuell: () => true, request, zeigeMeldung: (text, art) => meldungen.push({ text, art: art || 'ok' }) }
return { fx, panel, ctx, uploads, meldungen }
}
const mit = (optionen, fn) => async () => {
const t = starte(optionen)
try {
await render(t.panel, t.ctx)
await t.fx.ruhe()
await fn(t)
} finally {
t.fx.restore()
}
}
const aenderungen = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET')
const knopf = (fx, wurzel, aktion) => fx.finde(wurzel, `[data-aktion="${aktion}"]`)
const feldVon = (fx, wurzel, name) => fx.finde(wurzel, `[data-feld="${name}"]`)
const fehlerVon = (fx, wurzel, name) => fx.finde(wurzel, `[data-fehler="${name}"]`)
const zeile = (fx, wurzel, id) => fx.finde(wurzel, `[data-artikel="${id}"]`)
const bereich = (fx, wurzel, name) => fx.finde(wurzel, `[data-bereich="${name}"]`)
const datei = (name, typ, groesse) => ({ name, type: typ, size: groesse })
const DREI = () => [
artikel('a1', 'Mütze', { kategorie: 'Winter', sort: 0, bestand: 5, bilder: ['b1'] }),
artikel('a2', 'Schal', { kategorie: 'Winter', sort: 1, aktiv: false, bestand: 0 }),
artikel('a3', 'Handschuhe', { kategorie: 'Sommer', sort: 2, preisCent: null, varianten: { name: 'Größe', werte: [{ id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 2 }, { id: 'bbbbbbbb', name: 'L', preisCent: 1000, bestand: null }] } }),
]
/* ------------------------------------------------------------------ Liste --- */
test('Liste: Name, Preis, Bestand, Schalter „aktiv“, Hoch/Runter, Bearbeiten und Löschen je Artikel', mit({ produkte: DREI() }, async ({ fx, panel }) => {
assert.deepEqual(fx.fetchLog.map((a) => a.url).sort(), [`/api/shop/einstellungen?projectId=${P}`, `/api/shop/produkte?projectId=${P}`])
const reihenfolge = fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel'))
assert.deepEqual(reihenfolge, ['a1', 'a2', 'a3'])
const a1 = zeile(fx, panel, 'a1')
assert.match(fx.text(a1), /Mütze/)
assert.equal(fx.text(a1).includes(euroText(1250)), true)
assert.equal(fx.text(fx.finde(a1, '[data-bestand]')), '5 Stück')
assert.equal(fx.text(fx.finde(zeile(fx, panel, 'a2'), '[data-bestand]')), 'ausverkauft')
assert.equal(fx.text(fx.finde(zeile(fx, panel, 'a1'), '.sh-artikel-preis')), euroText(1250))
const a3 = zeile(fx, panel, 'a3')
assert.equal(fx.text(fx.finde(a3, '.sh-artikel-preis')), `ab ${euroText(800)}`, 'Varianten: ab dem kleinsten Preis')
assert.equal(fx.text(fx.finde(a3, '[data-bestand]')), 'unbegrenzt', 'ein Wert ohne Bestandsgrenze ⇒ unbegrenzt')
assert.equal(knopf(fx, a1, 'aktiv').checked, true)
assert.equal(knopf(fx, zeile(fx, panel, 'a2'), 'aktiv').checked, false)
assert.equal(knopf(fx, a1, 'hoch').disabled, true, 'der erste kann nicht höher')
assert.equal(knopf(fx, a3, 'runter').disabled, true, 'der letzte kann nicht tiefer')
assert.equal(knopf(fx, a1, 'runter').disabled, false)
for (const a of ['bearbeiten', 'loeschen']) assert.ok(knopf(fx, a1, a), a)
assert.equal(fx.finde(a1, 'img').getAttribute('src'), `/api/shop/bild/${P}/b1-s.webp`, 'Vorschau des Titelbilds')
assert.ok(knopf(fx, panel, 'artikel-neu'))
assert.match(fx.text(panel), /3 von höchstens 500 Artikeln/)
assert.deepEqual(aenderungen(fx), [])
}))
test('Liste ohne Artikel: Hinweis und Knopf „Artikel anlegen“, der den Editor öffnet', mit({ produkte: [] }, async ({ fx, panel }) => {
assert.match(fx.text(panel), /Du hast noch keine Artikel/)
assert.equal(fx.text(knopf(fx, panel, 'artikel-neu')), 'Artikel anlegen')
assert.equal(fx.finde(panel, '[data-artikel]'), null)
await fx.klick(knopf(fx, panel, 'artikel-neu'))
assert.ok(fx.finde(panel, 'form'), 'Editor statt Liste')
assert.equal(knopf(fx, panel, 'artikel-neu'), null)
assert.match(fx.text(panel), /Artikel anlegen/)
await fx.klick(knopf(fx, panel, 'abbrechen'))
assert.equal(fx.finde(panel, 'form'), null)
assert.ok(knopf(fx, panel, 'artikel-neu'))
}))
test('Laden schlägt fehl: Fehlerkarte mit „Noch einmal versuchen“', async () => {
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Interner Serverfehler' } }, { status: 500, body: { error: 'Interner Serverfehler' } }] })
try {
const panel = h('div')
fx.document.body.append(panel)
await render(panel, { projectId: P, nurLesen: false, request, zeigeMeldung() {} })
assert.match(fx.text(panel), /Interner Serverfehler/)
assert.match(fx.text(panel), /Noch einmal versuchen/)
} finally {
fx.restore()
}
})
/* ------------------------------------------------------------------ Schalter --- */
test('Schalter „aktiv“: PUT mit dem ganzen Artikel und umgedrehtem aktiv, danach zeigt die Liste den neuen Stand', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a1', 'Mütze', { kategorie: 'Winter', sort: 0, bestand: 5, bilder: ['b1'], aktiv: false }) } } : null),
}, async ({ fx, panel, meldungen }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv'))
const [put, ...rest] = aenderungen(fx)
assert.equal(rest.length, 0)
assert.equal(put.method, 'PUT')
assert.equal(put.url, '/api/shop/produkte/a1')
assert.deepEqual(put.body, { projectId: P, produkt: geladenKoerper(DREI()[0], { aktiv: false }) })
assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').checked, false)
assert.match(meldungen.at(-1).text, /Mütze.*inaktiv/)
}))
test('Schalter „aktiv“ schlägt fehl: Fehlermeldung, Schalter springt zurück', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { status: 500, body: { error: 'Interner Serverfehler' } } : null),
}, async ({ fx, panel, meldungen }) => {
const schalter = knopf(fx, zeile(fx, panel, 'a1'), 'aktiv')
await fx.klick(schalter)
assert.deepEqual(meldungen.at(-1), { text: 'Interner Serverfehler', art: 'fehler' })
assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').checked, true)
assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').disabled, false)
}))
/* ---------------------------------------------------------------- Sortierung --- */
test('Runter und Hoch: POST sortierung mit der neuen Reihenfolge, die Liste folgt der Antwort', mit({
produkte: DREI(),
sonst: (a) => {
if (a.url !== '/api/shop/produkte/sortierung') return null
const nach = (id) => DREI().find((x) => x.id === id)
return { body: { produkte: a.body.ids.map(nach) } }
},
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'runter'))
assert.deepEqual(aenderungen(fx).at(-1), { method: 'POST', url: '/api/shop/produkte/sortierung', body: { projectId: P, ids: ['a2', 'a1', 'a3'] } })
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a2', 'a1', 'a3'])
await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'hoch'))
assert.deepEqual(aenderungen(fx).at(-1).body.ids, ['a2', 'a3', 'a1'])
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a2', 'a3', 'a1'])
}))
/* -------------------------------------------------------------------- Anlegen --- */
test('Anlegen: Felder, Preis aus „12,5“, Grundpreis, Datalist mit vorhandenen Kategorien, POST mit allem', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Kerze', { sort: 3, preisCent: 1250 }) } } : null),
}, async ({ fx, panel, meldungen }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
const kategorienOptionen = fx.alle(form, 'datalist option').map((o) => o.getAttribute('value'))
assert.deepEqual(kategorienOptionen, ['Winter', 'Sommer'])
assert.equal(feldVon(fx, form, 'kategorie').getAttribute('list'), fx.finde(form, 'datalist').id)
assert.equal(bereich(fx, form, 'steuersatz').hidden, false, 'kein Kleinunternehmer ⇒ Steuersatz sichtbar')
assert.equal(bereich(fx, form, 'varianten').hidden, true)
await fx.setze(feldVon(fx, form, 'name'), 'Kerze')
await fx.setze(feldVon(fx, form, 'kategorie'), 'Deko')
await fx.setze(feldVon(fx, form, 'kurz'), 'Duftet gut')
await fx.setze(feldVon(fx, form, 'beschreibung'), 'Erster Absatz\n\nZweiter Absatz')
await fx.setze(feldVon(fx, form, 'preisCent'), '12,5')
assert.equal(feldVon(fx, form, 'preisCent').value, '12,50')
await fx.setze(feldVon(fx, form, 'steuersatz'), '7')
await fx.setze(feldVon(fx, form, 'grundpreis.menge'), '500')
await fx.setze(feldVon(fx, form, 'grundpreis.einheit'), 'g')
await fx.setze(feldVon(fx, form, 'bestand'), '8')
await fx.setze(feldVon(fx, form, 'lieferart'), 'abholung')
await fx.setze(feldVon(fx, form, 'widerruf'), 'verderblich')
await fx.klick(knopf(fx, form, 'speichern'))
assert.deepEqual(aenderungen(fx), [{
method: 'POST',
url: '/api/shop/produkte',
body: {
projectId: P,
produkt: {
...LEER_KOERPER,
name: 'Kerze',
kategorie: 'Deko',
kurz: 'Duftet gut',
beschreibung: 'Erster Absatz\n\nZweiter Absatz',
preisCent: 1250,
steuersatz: 7,
grundpreis: { menge: 500, einheit: 'g' },
bestand: 8,
lieferart: 'abholung',
widerruf: 'verderblich',
},
},
}])
assert.equal(fx.finde(panel, 'form'), null, 'zurück zur Liste')
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a1', 'a2', 'a3', 'neu'])
assert.deepEqual(meldungen.at(-1), { text: 'Der Artikel wurde angelegt.', art: 'ok' })
}))
test('Kleinunternehmer: Steuersatz ausgeblendet, gesendet wird 0; Bestand leer ⇒ unbegrenzt (null)', mit({
einstellungen: { kleinunternehmer: true },
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'X') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
assert.equal(bereich(fx, form, 'steuersatz').hidden, true)
await fx.setze(feldVon(fx, form, 'name'), 'X')
await fx.setze(feldVon(fx, form, 'preisCent'), '3')
await fx.klick(knopf(fx, form, 'speichern'))
const { produkt } = aenderungen(fx)[0].body
assert.equal(produkt.steuersatz, 0)
assert.equal(produkt.bestand, null)
assert.equal(produkt.preisCent, 300)
}))
test('Produktsicherheit und Lebensmittel: Hersteller „ein anderer“, EU-Verantwortlicher nur mit Haken, Pflichtangaben nur für Lebensmittel', mit({
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Honig') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
assert.equal(bereich(fx, form, 'hersteller').hidden, true)
assert.equal(bereich(fx, form, 'eu').hidden, true)
assert.equal(bereich(fx, form, 'lebensmittel').hidden, true)
await fx.setze(feldVon(fx, form, 'sicherheit.herstellerArt'), 'andere')
assert.equal(bereich(fx, form, 'hersteller').hidden, false)
await fx.setze(feldVon(fx, form, 'sicherheit.hersteller.name'), 'Imkerei Berg')
await fx.setze(feldVon(fx, form, 'sicherheit.hersteller.anschrift'), 'Dorfstr. 1, 12345 Berg')
await fx.setze(feldVon(fx, form, 'sicherheit.hersteller.kontakt'), 'berg@example.test')
await fx.klick(feldVon(fx, form, 'sicherheit.ausserhalbEu'))
assert.equal(bereich(fx, form, 'eu').hidden, false)
await fx.setze(feldVon(fx, form, 'sicherheit.euVerantwortlicher.name'), 'EU Vertretung GmbH')
await fx.setze(feldVon(fx, form, 'sicherheit.euVerantwortlicher.anschrift'), 'Hauptstr. 2, 10115 Berlin')
await fx.setze(feldVon(fx, form, 'sicherheit.euVerantwortlicher.kontakt'), 'eu@example.test')
await fx.setze(feldVon(fx, form, 'sicherheit.hinweise'), 'Nicht für Kinder unter 1 Jahr.')
await fx.klick(feldVon(fx, form, 'lebensmittel.aktiv'))
assert.equal(bereich(fx, form, 'lebensmittel').hidden, false)
assert.match(fx.text(form), /Bezeichnung, Zutaten, Allergene hervorgehoben, Menge, ggf\. Hersteller/)
await fx.setze(feldVon(fx, form, 'lebensmittel.pflichtangaben'), 'Honig, 250 g, enthält keine Allergene')
await fx.setze(feldVon(fx, form, 'name'), 'Honig')
await fx.setze(feldVon(fx, form, 'preisCent'), '6,90')
await fx.klick(knopf(fx, form, 'speichern'))
const { produkt } = aenderungen(fx)[0].body
assert.deepEqual(produkt.sicherheit, {
hersteller: { name: 'Imkerei Berg', anschrift: 'Dorfstr. 1, 12345 Berg', kontakt: 'berg@example.test' },
euVerantwortlicher: { name: 'EU Vertretung GmbH', anschrift: 'Hauptstr. 2, 10115 Berlin', kontakt: 'eu@example.test' },
hinweise: 'Nicht für Kinder unter 1 Jahr.',
})
assert.deepEqual(produkt.lebensmittel, { aktiv: true, pflichtangaben: 'Honig, 250 g, enthält keine Allergene' })
}))
/* ------------------------------------------------------------------ Varianten --- */
test('Varianten: Schalter blendet Preis und Bestand des Artikels aus, Zeilen hinzufügen und entfernen, Körper mit Werten', mit({
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Shirt') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
await fx.setze(feldVon(fx, form, 'name'), 'Shirt')
await fx.setze(feldVon(fx, form, 'preisCent'), '99')
await fx.setze(feldVon(fx, form, 'bestand'), '4')
await fx.klick(feldVon(fx, form, 'variantenAn'))
assert.equal(bereich(fx, form, 'varianten').hidden, false)
assert.equal(bereich(fx, form, 'einzelpreis').hidden, true)
assert.equal(bereich(fx, form, 'einzelbestand').hidden, true)
assert.equal(fx.alle(form, '[data-wert]').length, 1, 'eine leere Zeile zum Start')
await fx.setze(feldVon(fx, form, 'varianten.name'), 'Größe')
await fx.setze(feldVon(fx, form, 'varianten.werte.0.name'), 'S')
await fx.setze(feldVon(fx, form, 'varianten.werte.0.preisCent'), '10')
await fx.setze(feldVon(fx, form, 'varianten.werte.0.bestand'), '5')
await fx.klick(knopf(fx, form, 'wert-neu'))
await fx.klick(knopf(fx, form, 'wert-neu'))
assert.equal(fx.alle(form, '[data-wert]').length, 3)
await fx.setze(feldVon(fx, form, 'varianten.werte.1.name'), 'M')
await fx.setze(feldVon(fx, form, 'varianten.werte.1.preisCent'), '12,5')
await fx.setze(feldVon(fx, form, 'varianten.werte.2.name'), 'weg')
await fx.klick(fx.alle(form, '[data-aktion="wert-weg"]')[2])
assert.equal(fx.alle(form, '[data-wert]').length, 2)
assert.equal(feldVon(fx, form, 'varianten.werte.1.name').value, 'M', 'Eingaben bleiben beim Neuzeichnen erhalten')
await fx.klick(knopf(fx, form, 'speichern'))
assert.deepEqual(aenderungen(fx)[0].body.produkt, {
...LEER_KOERPER,
name: 'Shirt',
preisCent: null,
bestand: null,
varianten: { name: 'Größe', werte: [{ name: 'S', preisCent: 1000, bestand: 5 }, { name: 'M', preisCent: 1250, bestand: null }] },
})
}))
test('Varianten ausschalten: der Körper trägt wieder Preis und Bestand des Artikels, varianten = null', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a3', 'Handschuhe') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'bearbeiten'))
const form = fx.finde(panel, 'form')
assert.equal(feldVon(fx, form, 'variantenAn').checked, true)
assert.equal(feldVon(fx, form, 'varianten.name').value, 'Größe')
assert.equal(feldVon(fx, form, 'varianten.werte.1.name').value, 'L')
assert.equal(feldVon(fx, form, 'varianten.werte.0.preisCent').value, '8,00')
await fx.klick(feldVon(fx, form, 'variantenAn'))
await fx.setze(feldVon(fx, form, 'preisCent'), '9,99')
await fx.klick(knopf(fx, form, 'speichern'))
const put = aenderungen(fx)[0]
assert.equal(put.url, '/api/shop/produkte/a3')
assert.equal(put.body.produkt.varianten, null)
assert.equal(put.body.produkt.preisCent, 999)
}))
test('Bearbeiten: Werte-IDs der Varianten bleiben im Körper erhalten', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a3', 'Handschuhe') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'bearbeiten'))
await fx.klick(knopf(fx, fx.finde(panel, 'form'), 'speichern'))
assert.deepEqual(aenderungen(fx)[0].body.produkt.varianten.werte.map((w) => w.id), ['aaaaaaaa', 'bbbbbbbb'])
assert.deepEqual(aenderungen(fx)[0].body.produkt, geladenKoerper(DREI()[2]))
}))
/* --------------------------------------------------------------------- Bilder --- */
test('Bilder: roher Upload, Vorschau, „wird hochgeladen“, Titelbild, Entfernen, Bild-IDs im Körper', async () => {
let freigabe
const t = starte({
produkte: [],
upload: (aufruf, n) => (n === 1 ? new Promise((ok) => { freigabe = () => ok({ status: 201, body: { imageId: 'bild1', w: 800, h: 600 } }) }) : { status: 201, body: { imageId: `bild${n}` } }),
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Tasche') } } : null),
})
const { fx, panel, uploads } = t
try {
await render(panel, t.ctx)
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
const eingabe = fx.finde(form, 'input[type="file"]')
assert.match(eingabe.getAttribute('accept'), /image\/jpeg,image\/png,image\/webp/)
const erste = datei('a.jpg', 'image/jpeg', 1000)
eingabe.files = [erste]
const lauf = fx.sende(eingabe, 'change')
await fx.ruhe()
assert.match(fx.text(fx.finde(form, '[data-bild-status]')), /wird hochgeladen/)
assert.equal(eingabe.disabled, true)
assert.equal(knopf(fx, form, 'speichern').disabled, true, 'während des Uploads nicht speichern')
freigabe()
await lauf
assert.equal(uploads.length, 1)
assert.deepEqual(uploads[0], { method: 'POST', url: `/api/shop/bild?projectId=${P}`, datei: erste, credentials: 'same-origin' }, 'rohe Datei, kein JSON')
assert.equal(fx.text(fx.finde(form, '[data-bild-status]')), '')
assert.equal(eingabe.disabled, false)
assert.equal(knopf(fx, form, 'speichern').disabled, false)
assert.equal(fx.finde(form, '[data-bild="bild1"] img').getAttribute('src'), `/api/shop/bild/${P}/bild1-s.webp`)
assert.match(fx.text(fx.finde(form, '[data-bild="bild1"]')), /Titelbild/)
for (const name of ['b.png', 'c.webp']) {
eingabe.files = [datei(name, name.endsWith('png') ? 'image/png' : 'image/webp', 2000)]
await fx.sende(eingabe, 'change')
}
assert.deepEqual(fx.alle(form, '[data-bild]').map((li) => li.getAttribute('data-bild')), ['bild1', 'bild2', 'bild3'])
await fx.klick(knopf(fx, fx.finde(form, '[data-bild="bild3"]'), 'bild-titel'))
assert.deepEqual(fx.alle(form, '[data-bild]').map((li) => li.getAttribute('data-bild')), ['bild3', 'bild1', 'bild2'], 'erstes = Titelbild')
assert.equal(knopf(fx, fx.finde(form, '[data-bild="bild3"]'), 'bild-titel'), null, 'das Titelbild hat den Knopf nicht')
await fx.klick(knopf(fx, fx.finde(form, '[data-bild="bild1"]'), 'bild-weg'))
assert.deepEqual(fx.alle(form, '[data-bild]').map((li) => li.getAttribute('data-bild')), ['bild3', 'bild2'])
await fx.setze(feldVon(fx, form, 'name'), 'Tasche')
await fx.setze(feldVon(fx, form, 'preisCent'), '20')
await fx.klick(knopf(fx, form, 'speichern'))
assert.deepEqual(aenderungen(fx)[0].body.produkt.bilder, ['bild3', 'bild2'])
} finally {
fx.restore()
}
})
test('Bilder: höchstens vier, danach verschwindet das Feld; zu großes oder falsches Bild wird nicht gesendet', mit({ maxBilder: 2 }, async ({ fx, panel, uploads }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
const eingabe = fx.finde(form, 'input[type="file"]')
eingabe.files = [datei('riesig.jpg', 'image/jpeg', 9 * 1024 * 1024)]
await fx.sende(eingabe, 'change')
assert.equal(fehlerVon(fx, form, 'bilder').textContent, 'Das Bild ist größer als 8 MB.')
eingabe.files = [datei('x.gif', 'image/gif', 100)]
await fx.sende(eingabe, 'change')
assert.match(fehlerVon(fx, form, 'bilder').textContent, /JPEG-, PNG- oder WebP/)
assert.equal(uploads.length, 0)
for (const n of [1, 2]) {
eingabe.files = [datei(`${n}.jpg`, 'image/jpeg', 100)]
await fx.sende(eingabe, 'change')
}
assert.equal(fx.alle(form, '[data-bild]').length, 2)
assert.equal(eingabe.parentNode.hidden, true, 'Limit erreicht ⇒ kein weiteres Bild')
assert.equal(fehlerVon(fx, form, 'bilder').textContent, '', 'ein neuer Versuch löscht den alten Fehler')
}))
test('Bilder: Serverfehler 413 und 415 stehen am Bilder-Abschnitt, kein Bild kommt dazu', async () => {
const antworten = [{ status: 413, body: { error: 'Das Bild ist größer als 8 MB.' } }, { status: 415, body: {} }]
const t = starte({ upload: () => antworten.shift() })
try {
await render(t.panel, t.ctx)
const { fx, panel } = t
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
const eingabe = fx.finde(form, 'input[type="file"]')
eingabe.files = [datei('a.jpg', 'image/jpeg', 100)]
await fx.sende(eingabe, 'change')
assert.equal(fehlerVon(fx, form, 'bilder').textContent, 'Das Bild ist größer als 8 MB.')
eingabe.files = [datei('b.jpg', 'image/jpeg', 100)]
await fx.sende(eingabe, 'change')
assert.match(fehlerVon(fx, form, 'bilder').textContent, /JPEG-, PNG- oder WebP/)
assert.equal(fx.alle(form, '[data-bild]').length, 0)
assert.equal(eingabe.disabled, false)
} finally {
t.fx.restore()
}
})
/* ----------------------------------------------------------------- Feldfehler --- */
test('Feldfehler des Servers: Punkt-Pfade stehen am richtigen Feld, zugeklappte Bereiche gehen auf, der Fokus geht zum ersten Fehler', mit({
produkte: [artikel('a1', 'Shirt', {
preisCent: null,
grundpreis: { menge: 500, einheit: 'g' },
varianten: { name: 'Größe', werte: [{ id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 2 }, { id: 'bbbbbbbb', name: 'M', preisCent: 900, bestand: null }, { id: 'cccccccc', name: 'L', preisCent: 1000, bestand: null }] },
sicherheit: { hersteller: { name: 'Fabrik', anschrift: '', kontakt: 'f@example.test' }, euVerantwortlicher: null, hinweise: '' },
})],
sonst: (a) => (a.method === 'PUT'
? {
status: 400,
body: {
error: 'Bitte prüfe deine Eingaben.',
fehler: {
name: 'Bitte gib einen Namen für den Artikel an.',
'grundpreis.menge': 'Bitte gib die Menge für den Grundpreis als Zahl über 0 an.',
'varianten.werte.2.preisCent': 'Bitte gib den Preis des Werts als Betrag über 0 € an.',
'sicherheit.hersteller.anschrift': 'Bitte gib die Anschrift bei den Hersteller an.',
bilder: 'Eines der Bilder gibt es nicht (mehr).',
unbekannt: 'Irgendetwas stimmt nicht.',
},
},
}
: null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'bearbeiten'))
const form = fx.finde(panel, 'form')
const sicherheit = fx.finde(form, 'details')
assert.equal(sicherheit.hasAttribute('open'), true, 'Hersteller gesetzt ⇒ Produktsicherheit offen')
const lebensmittel = fx.alle(form, 'details')[1]
assert.equal(lebensmittel.hasAttribute('open'), false)
await fx.klick(knopf(fx, form, 'speichern'))
assert.equal(fehlerVon(fx, form, 'name').textContent, 'Bitte gib einen Namen für den Artikel an.')
assert.equal(fehlerVon(fx, form, 'grundpreis.menge').textContent, 'Bitte gib die Menge für den Grundpreis als Zahl über 0 an.')
assert.equal(fehlerVon(fx, form, 'varianten.werte.2.preisCent').textContent, 'Bitte gib den Preis des Werts als Betrag über 0 € an.')
assert.equal(fehlerVon(fx, form, 'sicherheit.hersteller.anschrift').textContent, 'Bitte gib die Anschrift bei den Hersteller an.')
assert.equal(fehlerVon(fx, form, 'bilder').textContent, 'Eines der Bilder gibt es nicht (mehr).')
assert.equal(fehlerVon(fx, form, '_').textContent, 'Irgendetwas stimmt nicht.', 'Fehler ohne Feld kommen allgemein oben')
for (const n of ['name', 'grundpreis.menge', 'varianten.werte.2.preisCent', 'sicherheit.hersteller.anschrift']) {
assert.equal(feldVon(fx, form, n).getAttribute('aria-invalid'), 'true', n)
}
assert.equal(feldVon(fx, form, 'varianten.werte.0.name').getAttribute('aria-invalid'), null)
assert.equal(fx.document.activeElement, feldVon(fx, form, 'name'), 'Fokus auf dem ersten Fehler')
assert.ok(fx.finde(panel, 'form'), 'der Editor bleibt offen, die Eingaben auch')
assert.equal(knopf(fx, form, 'speichern').disabled, false)
// ein neuer Versuch räumt die alten Fehler weg
await fx.setze(feldVon(fx, form, 'name'), 'Shirt')
fx.antworten.unshift(() => ({ body: { produkt: artikel('a1', 'Shirt') } }))
await fx.klick(knopf(fx, form, 'speichern'))
assert.equal(fx.finde(panel, 'form'), null)
}))
test('Höchstzahl der Artikel: die Meldung des Servers steht im Editor', mit({
sonst: (a) => (a.method === 'POST' ? { status: 409, body: { error: 'Du hast die Höchstzahl von 500 Artikeln erreicht. Lösche einen Artikel, bevor du einen neuen anlegst.' } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
await fx.setze(feldVon(fx, form, 'name'), 'Noch einer')
await fx.setze(feldVon(fx, form, 'preisCent'), '1')
await fx.klick(knopf(fx, form, 'speichern'))
assert.match(fehlerVon(fx, form, '_').textContent, /Höchstzahl von 500 Artikeln/)
}))
/* -------------------------------------------------------------------- Löschen --- */
test('Löschen: Rückfrage in der Seite, Abbrechen tut nichts, „Ja, löschen“ schickt DELETE und entfernt die Zeile', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'DELETE' ? { body: { ok: true } } : null),
}, async ({ fx, panel, meldungen }) => {
const a2 = zeile(fx, panel, 'a2')
await fx.klick(knopf(fx, a2, 'loeschen'))
assert.match(fx.text(a2), /„Schal“ wirklich löschen\?/)
assert.deepEqual(aenderungen(fx), [], 'noch nichts gelöscht')
await fx.klick(knopf(fx, a2, 'loeschen-nein'))
assert.equal(fx.finde(a2, '.sh-frage-box'), null)
assert.deepEqual(aenderungen(fx), [])
await fx.klick(knopf(fx, a2, 'loeschen'))
await fx.klick(knopf(fx, a2, 'loeschen-ja'))
assert.deepEqual(aenderungen(fx), [{ method: 'DELETE', url: `/api/shop/produkte/a2?projectId=${P}`, body: undefined }])
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a1', 'a3'])
assert.match(meldungen.at(-1).text, /Schal.*gelöscht/)
}))
test('Löschen schlägt fehl: Meldung, Zeile bleibt', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'DELETE' ? { status: 404, body: { error: 'Diesen Artikel gibt es nicht (mehr).' } } : null),
}, async ({ fx, panel, meldungen }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a2'), 'loeschen'))
await fx.klick(knopf(fx, zeile(fx, panel, 'a2'), 'loeschen-ja'))
assert.deepEqual(meldungen.at(-1), { text: 'Diesen Artikel gibt es nicht (mehr).', art: 'fehler' })
assert.ok(zeile(fx, panel, 'a2'))
}))
/* ------------------------------------------------------------------- nurLesen --- */
test('nurLesen: nur die Liste, keine Knöpfe und keine Schalter', mit({ produkte: DREI(), nurLesen: true }, async ({ fx, panel }) => {
assert.equal(fx.finde(panel, 'button'), null)
assert.equal(fx.finde(panel, 'input'), null)
assert.match(fx.text(zeile(fx, panel, 'a1')), /Mütze/)
assert.match(fx.text(zeile(fx, panel, 'a1')), /aktiv/)
assert.match(fx.text(zeile(fx, panel, 'a2')), /inaktiv/)
}))
test('nurLesen ohne Artikel: Hinweis ohne „Artikel anlegen“', mit({ produkte: [], nurLesen: true }, async ({ fx, panel }) => {
assert.match(fx.text(panel), /Es gibt noch keine Artikel\./)
assert.equal(fx.finde(panel, 'button'), null)
}))
/* --------------------------------------------------------------------- Statik --- */
test('Statik: kein innerHTML, outerHTML, insertAdjacentHTML oder document.write; Fremdtext nur über h()', () => {
const quelle = readFileSync(new URL('../public/shop-tab-artikel.js', import.meta.url), 'utf8')
const code = quelle.replace(/\/\*[\s\S]*?\*\//g, '').split('\n').filter((z) => !z.trim().startsWith('//')).join('\n')
for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function']) {
assert.equal(code.includes(verboten), false, verboten)
}
assert.doesNotMatch(code, /from '\.\/termine/, 'keine Importe aus termine*.js')
})
test('Bearbeiten I3: der Körper trägt den beim Laden gesehenen Bestand (bestandGeladen) neben dem Feldwert, je Variantenwert; neue Werte und neue Artikel nicht', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a3', 'Handschuhe') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'bearbeiten'))
const form = fx.finde(panel, 'form')
await fx.setze(feldVon(fx, form, 'varianten.werte.0.bestand'), '9')
await fx.klick(knopf(fx, form, 'wert-neu'))
await fx.setze(feldVon(fx, form, 'varianten.werte.2.name'), 'XL')
await fx.setze(feldVon(fx, form, 'varianten.werte.2.preisCent'), '11')
await fx.setze(feldVon(fx, form, 'varianten.werte.2.bestand'), '3')
await fx.klick(knopf(fx, form, 'speichern'))
const { produkt } = aenderungen(fx)[0].body
assert.equal(produkt.bestandGeladen, null)
assert.deepEqual(produkt.varianten.werte.map((w) => [w.id, w.bestand, w.bestandGeladen]), [['aaaaaaaa', 9, 2], ['bbbbbbbb', null, null], [undefined, 3, undefined]])
}))
test('Bearbeiten I3: Einzelbestand - geändert wird der neue Wert gesendet, geladen bleibt der alte', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a1', 'Mütze') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'bearbeiten'))
const form = fx.finde(panel, 'form')
await fx.setze(feldVon(fx, form, 'bestand'), '8')
await fx.klick(knopf(fx, form, 'speichern'))
const { produkt } = aenderungen(fx)[0].body
assert.deepEqual([produkt.bestand, produkt.bestandGeladen], [8, 5])
}))
+523
View File
@@ -0,0 +1,523 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { euroText, h, request } from '../public/shop-dom.js'
import { render } from '../public/shop-tab-bestellungen.js'
// Task G5: Reiter „Bestellungen“ (public/shop-tab-bestellungen.js) mit Fake-DOM und Fake-fetch. Die Antworten sind von Hand gebaut
// (Form: server/routes/shop.js und server/services/shop/bestellungen.js listenZeile/bestellungKunde, widerrufe.js ansicht).
const zeile = (nr, extra = {}) => ({
id: `o${nr}`, bestellnummer: `WK-${nr}`, status: 'bezahlt', erstelltAt: '2026-10-05T10:00:00.000Z', bezahltAt: '2026-10-05T10:05:00.000Z',
summeCent: 4590, summeText: '45,90 €', erstattetCent: 0, kaeuferName: `Kunde ${nr}`, kaeuferEmail: `k${nr}@example.com`, lieferart: 'versand',
anzahlArtikel: 2, ueberverkauft: false, spaet: false, ...extra,
})
const liste = (zeilen, extra = {}) => ({ bestellungen: zeilen, seite: 1, mehr: false, abgeschnitten: false, ...extra })
const detail = (extra = {}) => ({
id: 'o1', bestellnummer: 'WK-1', status: 'bezahlt', erstelltAt: '2026-10-05T10:00:00.000Z', bezahltAt: '2026-10-05T10:05:00.000Z',
summeCent: 4590, summeText: '45,90 €', erstattetCent: 0, erstattetText: '0,00 €', restCent: 4590, restText: '45,90 €',
kaeufer: { name: 'Anna <b>Beispiel</b>', email: 'anna@example.com', telefon: '0151 123' }, // alte API-Antwort: die Anzeige zeigt keine Telefonnummer
lieferart: 'versand', land: 'DE',
lieferadresse: { name: 'Anna Beispiel', line1: 'Hauptstr. 1', line2: '', postal_code: '10115', city: 'Berlin', country: 'DE' },
rechnungsadresse: { name: 'Anna Beispiel', line1: 'Hauptstr. 1', line2: '', postal_code: '10115', city: 'Berlin', country: 'DE' },
abholung: null, zahlungsart: 'card', lieferzeit: '2–4 Werktage',
positionen: [
{ name: 'Mütze', varianteText: 'Blau', menge: 2, summeCent: 3000, summeText: '30,00 €' },
{ name: 'Schal', varianteText: '', menge: 1, summeCent: 1590, summeText: '15,90 €' },
],
summen: { zwischensummeCent: 4590, versandCent: 0, gesamtCent: 4590, steuer: [], texte: { zwischensumme: '45,90 €', versand: 'kostenlos', gesamt: '45,90 €', steuerHinweis: 'Kein Steuerausweis nach § 19 UStG' } },
versand: null, ueberverkauft: false, spaet: false,
verlauf: [{ at: '2026-10-05T10:00:00.000Z', was: 'angelegt' }, { at: '2026-10-05T10:05:00.000Z', was: 'bezahlt' }],
erstattungen: [], aktionen: ['versendet', 'abgeschlossen'], kannErstatten: true, ...extra,
})
const widerruf = (nr, extra = {}) => ({
id: `w${nr}`, bestellnummer: `WK-${nr}`, name: `Wida ${nr}`, email: `w${nr}@example.com`, nachricht: 'Ich widerrufe <script>alert(1)</script>',
eingangAt: '2026-10-05T12:30:00.000Z', bestaetigungAt: '2026-10-05T12:30:05.000Z', bearbeitetAt: null, orderId: `o${nr}`, ...extra,
})
/** Fake-Server: jede Anfrage geht an route(aufruf) ⇒ { status, body }; Standard 200 {}. */
const starte = (route) => installFakeDom({ antworten: Array.from({ length: 60 }, () => route) })
function baueCtx({ nurLesen = false, abwickeln } = {}) {
const ctx = {
projectId: 'p1', status: { state: 'active' }, nurLesen, ...(abwickeln ? { abwickeln } : {}), meldungen: [], aktuell: () => true, request,
zeigeMeldung: (text, art) => ctx.meldungen.push({ text, art }),
neuLaden: async () => ctx.status,
}
return ctx
}
async function zeige(fx, optionen = {}) {
const ctx = baueCtx(optionen)
const panel = h('div', { id: 'sh-panel' })
fx.document.body.append(panel)
await render(panel, ctx)
await fx.ruhe()
return { panel, ctx }
}
const mit = (route, fn) => async () => {
const fx = starte(route)
try {
await fn(fx)
} finally {
fx.restore()
}
}
const aktion = (fx, el, name) => fx.finde(el, `[data-aktion="${name}"]`)
const gets = (fx) => fx.fetchLog.filter((a) => a.method === 'GET').map((a) => a.url)
const posts = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET')
const letzteMeldung = (ctx) => ctx.meldungen.at(-1)
const mailto = (fx, el) => fx.finde(el, (n) => n.tagName === 'A' && (n.getAttribute('href') || '').startsWith('mailto:'))
/** Standard-Server: Liste mit zwei Zeilen, Detail, Aktionen und Erstattung. */
const server = (ueberschreibe = {}) => (a) => {
const u = a.url
if (ueberschreibe[`${a.method} ${u.split('?')[0]}`]) return ueberschreibe[`${a.method} ${u.split('?')[0]}`](a)
if (a.method === 'GET' && u.startsWith('/api/shop/bestellungen?')) return { body: liste([zeile(1), zeile(2, { status: 'versendet', lieferart: 'abholung' })]) }
if (a.method === 'GET' && u.startsWith('/api/shop/bestellungen/')) return { body: { bestellung: detail() } }
if (a.method === 'GET' && u.startsWith('/api/shop/widerrufe?')) return { body: { widerrufe: [widerruf(1), widerruf(2, { bearbeitetAt: '2026-10-06T08:00:00.000Z' })], seite: 1, mehr: false } }
return { body: {} }
}
async function oeffneDetail(fx, optionen) {
const r = await zeige(fx, optionen)
await fx.klick(fx.finde(r.panel, '[data-bestellung="o1"]'))
await fx.ruhe()
return r
}
/* ----------------------------------------------------------------- Liste --- */
test('Liste: Standard ist „bezahlt“, Zeilen zeigen Nummer, Datum, Name, Lieferart, Gesamt und Status', mit(server(), async (fx) => {
const { panel } = await zeige(fx)
assert.deepEqual(gets(fx), ['/api/shop/bestellungen?projectId=p1&status=bezahlt&seite=1'])
const zeilen = fx.alle(panel, '[data-bestellung]')
assert.equal(zeilen.length, 2)
const text = fx.text(zeilen[0])
assert.match(text, /WK-1/)
assert.match(text, /05\.10\.2026/)
assert.match(text, /Kunde 1/)
assert.match(text, /Versand/)
assert.match(text, /45,90 €/)
assert.match(text, /Bezahlt/)
assert.match(fx.text(zeilen[1]), /Abholung/)
assert.match(fx.text(zeilen[1]), /Versendet/)
assert.equal(aktion(fx, panel, 'weiter'), null, 'ohne weitere Seite keine Blätterleiste')
}))
test('Filter und Suche setzen die Abfrage (Status, Suche kodiert, zurück auf Seite 1)', mit(server(), async (fx) => {
const { panel } = await zeige(fx)
await fx.setze(fx.finde(panel, '#sh-b-filter'), 'versendet')
await fx.ruhe()
assert.equal(gets(fx).at(-1), '/api/shop/bestellungen?projectId=p1&status=versendet&seite=1')
await fx.setze(fx.finde(panel, '#sh-b-suche'), ' Anna Müller & Co ')
await fx.klick(aktion(fx, panel, 'suchen'))
await fx.ruhe()
assert.equal(gets(fx).at(-1), `/api/shop/bestellungen?projectId=p1&status=versendet&suche=${encodeURIComponent('Anna Müller & Co')}&seite=1`)
await fx.setze(fx.finde(panel, '#sh-b-filter'), 'alle')
await fx.ruhe()
assert.match(gets(fx).at(-1), /status=alle&suche=Anna/, 'die Suche bleibt beim Wechsel des Filters')
}))
test('Blättern: Weiter und Zurück fragen die nächste Seite, die Leiste folgt dem Server', mit((a) => {
if (a.url.startsWith('/api/shop/bestellungen?')) {
const seite = Number(/seite=(\d+)/.exec(a.url)[1])
return { body: liste([zeile(seite)], { seite, mehr: seite < 2 }) }
}
return { body: {} }
}, async (fx) => {
const { panel } = await zeige(fx)
assert.equal(aktion(fx, panel, 'zurueck').hasAttribute('disabled'), true)
await fx.klick(aktion(fx, panel, 'weiter'))
await fx.ruhe()
assert.match(gets(fx).at(-1), /seite=2$/)
assert.match(fx.text(panel), /Seite 2/)
assert.equal(aktion(fx, panel, 'weiter').hasAttribute('disabled'), true)
await fx.klick(aktion(fx, panel, 'zurueck'))
await fx.ruhe()
assert.match(gets(fx).at(-1), /seite=1$/)
assert.match(fx.text(panel), /WK-1/)
}))
test('Leere Liste, Ladefehler und Hinweis bei abgeschnittener Suche', mit((a) => {
if (a.url.includes('status=erstattet')) return { status: 500, body: { error: 'Server kaputt.' } }
if (a.url.includes('status=alle')) return { body: liste([zeile(1)], { abgeschnitten: true }) }
return { body: liste([]) }
}, async (fx) => {
const { panel } = await zeige(fx)
assert.ok(fx.finde(panel, '[data-leer]'))
await fx.setze(fx.finde(panel, '#sh-b-filter'), 'erstattet')
await fx.ruhe()
assert.match(fx.text(panel), /Server kaputt\./)
await fx.setze(fx.finde(panel, '#sh-b-filter'), 'alle')
await fx.ruhe()
assert.match(fx.text(panel), /nur die neuesten Bestellungen durchsucht/)
}))
/* ---------------------------------------------------------------- Detail --- */
test('Detail: Positionen, Summen, Käufer, Adresse, Zahlung, Verlauf; Fremdtext bleibt Text', mit(server(), async (fx) => {
const { panel } = await oeffneDetail(fx)
assert.equal(gets(fx).at(-1), '/api/shop/bestellungen/o1?projectId=p1')
const text = fx.text(panel)
for (const erwartet of ['Bestellung WK-1', '2 × Mütze (Blau)', '30,00 €', '1 × Schal', '15,90 €', 'kostenlos', 'Kein Steuerausweis', 'Anna <b>Beispiel</b>', 'Hauptstr. 1', '10115 Berlin', 'Karte', '2–4 Werktage', 'Bestellung angelegt', 'Bezahlt']) {
assert.ok(text.includes(erwartet), erwartet)
}
assert.equal(fx.alle(panel, 'b').length, 0, 'Käufername ist Text, kein Markup')
assert.ok(!text.includes('0151 123') && !text.includes('Telefon'), 'keine Käufer-Telefonnummer in der Anzeige')
const mail = mailto(fx, panel)
assert.equal(mail.getAttribute('href'), 'mailto:anna@example.com')
assert.equal(fx.finde(panel, '[data-hinweis="sepa"]'), null)
await fx.klick(aktion(fx, panel, 'liste'))
await fx.ruhe()
assert.ok(fx.finde(panel, '#sh-b-filter'), 'zurück in der Liste')
}))
test('Detail: Abholung zeigt Adresse und Hinweis, keine Lieferadresse; E-Mail mit Sonderzeichen ist kein Link', mit(server({
'GET /api/shop/bestellungen/o1': () => ({ body: { bestellung: detail({ lieferart: 'abholung', lieferadresse: null, abholung: { adresse: 'Laden 5, 80331 München', hinweis: 'Mo–Fr 10–18 Uhr' }, kaeufer: { name: 'A', email: 'a@b.de"><x' }, aktionen: ['abholbereit', 'abgeschlossen'] }) } }),
}), async (fx) => {
const { panel } = await oeffneDetail(fx)
const text = fx.text(panel)
assert.ok(text.includes('Laden 5, 80331 München'))
assert.ok(text.includes('Mo–Fr 10–18 Uhr'))
assert.ok(!text.includes('Lieferadresse'))
assert.equal(mailto(fx, panel), null)
assert.ok(aktion(fx, panel, 'abholbereit'))
assert.equal(aktion(fx, panel, 'versendet'), null)
}))
test('Detail: SEPA-Hinweis nur bei Lastschrift und Status bezahlt', mit(server({
'GET /api/shop/bestellungen/o1': () => ({ body: { bestellung: detail({ zahlungsart: 'sepa_debit' }) } }),
}), async (fx) => {
const { panel } = await oeffneDetail(fx)
assert.equal(fx.text(fx.finde(panel, '[data-hinweis="sepa"]')), 'Lastschrift – versende erst, wenn das Geld sicher ist (nach einigen Tagen im Stripe-Dashboard sichtbar).')
assert.ok(fx.text(panel).includes('SEPA-Lastschrift'))
}))
test('Detail: SEPA ohne Hinweis, wenn schon versendet', mit(server({
'GET /api/shop/bestellungen/o1': () => ({ body: { bestellung: detail({ zahlungsart: 'sepa_debit', status: 'versendet', aktionen: ['abgeschlossen'] }) } }),
}), async (fx) => {
const { panel } = await oeffneDetail(fx)
assert.equal(fx.finde(panel, '[data-hinweis="sepa"]'), null)
}))
test('Detail: Ladefehler führt zurück in die Liste mit Meldung', mit(server({
'GET /api/shop/bestellungen/o1': () => ({ status: 404, body: { error: 'Diese Bestellung gibt es nicht.' } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
assert.ok(fx.finde(panel, '#sh-b-filter'))
assert.deepEqual(letzteMeldung(ctx), { text: 'Diese Bestellung gibt es nicht.', art: 'fehler' })
}))
/* -------------------------------------------------------------- Aktionen --- */
test('Versendet: Formular mit Versanddienst und Sendungsnummer, Body, Meldung, Detail zeigt neuen Stand', mit(server({
'POST /api/shop/bestellungen/o1/aktion': () => ({ body: { bestellung: detail({ status: 'versendet', aktionen: ['abgeschlossen'], kannErstatten: true, versand: { versanddienst: 'DHL', sendungsnummer: 'JJD123' } }), mailOk: true } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'versendet'))
assert.equal(posts(fx).length, 0, 'erst das Formular')
const form = fx.finde(panel, 'form[aria-label="Als versendet markieren"]')
await fx.setze(fx.finde(form, '[data-feld="versanddienst"]'), ' DHL ')
await fx.setze(fx.finde(form, '[data-feld="sendungsnummer"]'), 'JJD123')
await fx.klick(aktion(fx, form, 'versendet-speichern'))
await fx.ruhe()
assert.deepEqual(posts(fx).map((p) => [p.url, p.body]), [['/api/shop/bestellungen/o1/aktion', { projectId: 'p1', aktion: 'versendet', versanddienst: 'DHL', sendungsnummer: 'JJD123' }]])
assert.match(letzteMeldung(ctx).text, /Als versendet gespeichert/)
assert.equal(letzteMeldung(ctx).art, 'ok')
assert.equal(aktion(fx, panel, 'versendet'), null)
assert.ok(fx.text(panel).includes('DHL, JJD123'))
}))
test('Versendet ohne Angaben schickt leere Texte; Abholbereit und Abgeschlossen gehen direkt', mit(server({
'POST /api/shop/bestellungen/o1/aktion': (a) => ({ body: { bestellung: detail({ aktionen: ['abholbereit', 'abgeschlossen', 'versendet'] }), mailOk: a.body.aktion !== 'abholbereit' || true } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'versendet'))
await fx.klick(aktion(fx, panel, 'versendet-speichern'))
await fx.ruhe()
assert.deepEqual(posts(fx)[0].body, { projectId: 'p1', aktion: 'versendet', versanddienst: '', sendungsnummer: '' })
await fx.klick(aktion(fx, panel, 'abholbereit'))
await fx.ruhe()
assert.deepEqual(posts(fx)[1].body, { projectId: 'p1', aktion: 'abholbereit' })
assert.match(letzteMeldung(ctx).text, /abholbereit/)
await fx.klick(aktion(fx, panel, 'abgeschlossen'))
await fx.ruhe()
assert.deepEqual(posts(fx)[2].body, { projectId: 'p1', aktion: 'abgeschlossen' })
}))
test('mailOk false: Meldung „Gespeichert, aber die Mail an den Käufer ging nicht raus.“', mit(server({
'POST /api/shop/bestellungen/o1/aktion': () => ({ body: { bestellung: detail({ status: 'versendet', aktionen: ['abgeschlossen'] }), mailOk: false } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'abgeschlossen'))
await fx.ruhe()
assert.deepEqual(letzteMeldung(ctx), { text: 'Gespeichert, aber die Mail an den Käufer ging nicht raus.', art: 'fehler' })
assert.match(fx.text(panel), /Versendet/)
}))
test('Stornieren: nur wenn der Server es anbietet, erst nach Rückfrage', mit(server({
'GET /api/shop/bestellungen/o1': () => ({ body: { bestellung: detail({ status: 'offen', aktionen: ['storniert'], kannErstatten: false }) } }),
'POST /api/shop/bestellungen/o1/aktion': () => ({ body: { bestellung: detail({ status: 'storniert', aktionen: [], kannErstatten: false }), mailOk: true } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'storniert'))
assert.equal(posts(fx).length, 0)
assert.ok(fx.text(panel).includes('Bestellung wirklich stornieren?'))
await fx.klick(aktion(fx, panel, 'abbrechen'))
assert.equal(fx.finde(panel, 'form[aria-label="Bestellung wirklich stornieren?"]'), null)
await fx.klick(aktion(fx, panel, 'storniert'))
await fx.klick(aktion(fx, panel, 'stornieren-bestaetigen'))
await fx.ruhe()
assert.deepEqual(posts(fx).map((p) => p.body), [{ projectId: 'p1', aktion: 'storniert' }])
assert.match(letzteMeldung(ctx).text, /storniert/)
assert.equal(aktion(fx, panel, 'storniert'), null)
}))
test('Aktionsfehler (403 Kundenansicht) zeigt den Text des Servers', mit(server({
'POST /api/shop/bestellungen/o1/aktion': () => ({ status: 403, body: { error: 'In der Kundenansicht kann das nicht geändert werden.' } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'abgeschlossen'))
await fx.ruhe()
assert.deepEqual(letzteMeldung(ctx), { text: 'In der Kundenansicht kann das nicht geändert werden.', art: 'fehler' })
assert.ok(aktion(fx, panel, 'abgeschlossen'), 'Detail bleibt stehen')
}))
/* -------------------------------------------------------------- Erstatten --- */
test('Erstatten: Betrag vorbelegt mit dem Rest, Rückfrage mit Betrag, Body in Cent und Grund', mit(server({
'POST /api/shop/bestellungen/o1/erstatten': () => ({ body: {
bestellung: detail({ erstattetCent: 1250, erstattetText: '12,50 €', restCent: 3340, restText: '33,40 €', erstattungen: [{ rid: 're_1', betragCent: 1250, grund: 'Kulanz', at: '2026-10-06T08:00:00.000Z' }] }),
erstattung: { rid: 're_1', betragCent: 1250, grund: 'Kulanz' }, voll: false, mailOk: true,
} }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
const form = fx.finde(panel, 'form[data-form="erstatten"]')
const betrag = fx.finde(form, '#sh-erstatten-betrag')
assert.equal(betrag.value, '45,90')
await fx.setze(betrag, '12,5')
await fx.setze(fx.finde(form, '[data-feld="grund"]'), ' Kulanz ')
await fx.klick(aktion(fx, form, 'erstatten'))
assert.equal(posts(fx).length, 0, 'erst die Rückfrage')
assert.ok(fx.text(panel).includes(`Wirklich ${euroText(1250)} an den Käufer zurückzahlen? Das geht nicht rückgängig.`))
await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
await fx.ruhe()
assert.deepEqual(posts(fx).map((p) => [p.url, p.body]), [['/api/shop/bestellungen/o1/erstatten', { projectId: 'p1', betragCent: 1250, grund: 'Kulanz' }]])
assert.ok(letzteMeldung(ctx).text.startsWith(`${euroText(1250)} erstattet`))
assert.ok(!letzteMeldung(ctx).text.includes('vollständig'))
const erst = fx.finde(panel, '[data-liste="erstattungen"]')
assert.ok(fx.text(erst).includes(`${euroText(1250)}: Kulanz`))
assert.equal(fx.finde(panel, '#sh-erstatten-betrag').value, '33,40', 'neuer Rest')
}))
test('Erstatten: ohne Grund wird kein Grund gesendet, Abbrechen sendet nichts', mit(server({
'POST /api/shop/bestellungen/o1/erstatten': () => ({ body: { bestellung: detail({ status: 'erstattet', aktionen: [], kannErstatten: false }), erstattung: { betragCent: 4590 }, voll: true, mailOk: true } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'erstatten'))
await fx.klick(aktion(fx, panel, 'abbrechen'))
assert.equal(posts(fx).length, 0)
await fx.klick(aktion(fx, panel, 'erstatten'))
await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
await fx.ruhe()
assert.deepEqual(posts(fx)[0].body, { projectId: 'p1', betragCent: 4590 })
assert.match(letzteMeldung(ctx).text, /vollständig erstattet/)
assert.equal(fx.finde(panel, 'form[data-form="erstatten"]'), null, 'nichts mehr zu erstatten')
}))
test('Erstatten: unlesbarer, zu hoher und leerer Betrag werden vor der Rückfrage abgelehnt', mit(server(), async (fx) => {
const { panel } = await oeffneDetail(fx)
const form = fx.finde(panel, 'form[data-form="erstatten"]')
const betrag = fx.finde(form, '#sh-erstatten-betrag')
for (const [eingabe, muster] of [['zwölf', /Betrag in Euro an/], ['99,00', new RegExp(`höchstens ${euroText(4590)}`)], ['0', /größer als 0/], ['', /Betrag in Euro an/]]) {
await fx.setze(betrag, eingabe)
await fx.klick(aktion(fx, form, 'erstatten'))
assert.match(fx.text(fx.finde(form, '[data-fehler="betrag"]')), muster, eingabe)
}
assert.equal(fx.finde(panel, '[data-aktion="erstatten-bestaetigen"]'), null)
assert.equal(posts(fx).length, 0)
}))
test('Erstatten: Stripe-Fehler 502 zeigt den Text am Formular, nichts wird als erstattet gezeigt', mit(server({
'POST /api/shop/bestellungen/o1/erstatten': () => ({ status: 502, body: { error: 'Die Erstattung ist bei Stripe fehlgeschlagen. Bitte versuch es später noch einmal.' } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'erstatten'))
await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
await fx.ruhe()
const form = fx.finde(panel, 'form[data-form="erstatten"]')
assert.match(fx.text(fx.finde(form, '[data-fehler="_"]')), /bei Stripe fehlgeschlagen/)
assert.equal(fx.finde(panel, '[data-aktion="erstatten-bestaetigen"]'), null, 'Rückfrage ist zu')
assert.equal(ctx.meldungen.filter((m) => m.art === 'ok' && m.text).length, 0)
assert.equal(fx.alle(panel, 'button').some((b) => b.hasAttribute('disabled')), false, 'Knöpfe sind wieder frei')
}))
test('Erstatten: Feldfehler des Servers (betrag) landet am Feld', mit(server({
'POST /api/shop/bestellungen/o1/erstatten': () => ({ status: 400, body: { error: 'x', fehler: { betrag: 'Bitte gib einen Betrag zwischen 0,01 € und 10,00 € an.' } } }),
}), async (fx) => {
const { panel } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'erstatten'))
await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
await fx.ruhe()
assert.match(fx.text(fx.finde(panel, '[data-fehler="betrag"]')), /zwischen 0,01 €/)
}))
test('Erstatten mit mailOk false meldet die fehlende Mail', mit(server({
'POST /api/shop/bestellungen/o1/erstatten': () => ({ body: { bestellung: detail(), erstattung: { betragCent: 4590 }, voll: true, mailOk: false } }),
}), async (fx) => {
const { panel, ctx } = await oeffneDetail(fx)
await fx.klick(aktion(fx, panel, 'erstatten'))
await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
await fx.ruhe()
assert.deepEqual(letzteMeldung(ctx), { text: 'Gespeichert, aber die Mail an den Käufer ging nicht raus.', art: 'fehler' })
}))
test('Doppelklick auf eine Aktion schickt nur einen Aufruf', mit(server({
'POST /api/shop/bestellungen/o1/aktion': () => ({ body: { bestellung: detail({ status: 'abgeschlossen', aktionen: [] }), mailOk: true } }),
}), async (fx) => {
const { panel } = await oeffneDetail(fx)
const knopf = aktion(fx, panel, 'abgeschlossen')
await Promise.all([fx.klick(knopf), fx.klick(knopf)])
await fx.ruhe()
assert.equal(posts(fx).length, 1)
}))
/* ------------------------------------------------------------------- CSV --- */
test('CSV: Link mit Projekt, Datum nach Eingabe, kodiert, nur Pfad auf dem eigenen Server', mit(server(), async (fx) => {
const { panel } = await zeige(fx)
const link = fx.finde(panel, '[data-link="csv"]')
assert.equal(link.getAttribute('href'), '/api/shop/bestellungen.csv?projectId=p1')
assert.ok(link.hasAttribute('download'))
await fx.setze(fx.finde(panel, '#sh-csv-von'), '2026-10-01')
assert.equal(link.getAttribute('href'), '/api/shop/bestellungen.csv?projectId=p1&von=2026-10-01')
await fx.setze(fx.finde(panel, '#sh-csv-bis'), '2026-10-31')
assert.equal(link.getAttribute('href'), '/api/shop/bestellungen.csv?projectId=p1&von=2026-10-01&bis=2026-10-31')
assert.ok(link.getAttribute('href').startsWith('/api/'))
}))
test('CSV: die Projekt-ID wird kodiert', async () => {
const fx = starte(server())
try {
const ctx = { ...baueCtx(), projectId: 'a&b=c' }
const panel = h('div')
fx.document.body.append(panel)
await render(panel, ctx)
await fx.ruhe()
assert.equal(fx.finde(panel, '[data-link="csv"]').getAttribute('href'), `/api/shop/bestellungen.csv?projectId=${encodeURIComponent('a&b=c')}`)
} finally {
fx.restore()
}
})
/* -------------------------------------------------------------- Widerrufe --- */
test('Widerrufe: Liste mit Eingang, Name, E-Mail, Nummer, Erklärung und Stand; Hinweis', mit(server(), async (fx) => {
const { panel } = await zeige(fx)
await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
await fx.ruhe()
assert.equal(gets(fx).at(-1), '/api/shop/widerrufe?projectId=p1&seite=1')
assert.ok(fx.text(panel).includes('Ein Widerruf ist erst erledigt, wenn du die Ware zurückhast oder erstattet hast (spätestens 14 Tage nach Eingang).'))
const w1 = fx.finde(panel, '[data-widerruf="w1"]')
const text = fx.text(w1)
for (const erwartet of ['05.10.2026', '14:30', 'Wida 1', 'w1@example.com', 'WK-1', 'Ich widerrufe <script>alert(1)</script>', 'offen']) assert.ok(text.includes(erwartet), erwartet)
assert.equal(fx.alle(panel, 'script').length, 0)
assert.equal(mailto(fx, w1).getAttribute('href'), 'mailto:w1@example.com')
assert.match(fx.text(fx.finde(panel, '[data-widerruf="w2"]')), /erledigt am 06\.10\.2026/)
assert.ok(aktion(fx, w1, 'erledigt'))
assert.equal(aktion(fx, fx.finde(panel, '[data-widerruf="w2"]'), 'erledigt'), null)
}))
test('Widerruf erledigt: POST, Meldung, Liste wird neu geladen', async () => {
let erledigt = false
const fx = starte((a) => {
if (a.method === 'POST') { erledigt = true; return { body: { widerruf: widerruf(1, { bearbeitetAt: '2026-10-06T08:00:00.000Z' }) } } }
if (a.url.startsWith('/api/shop/widerrufe?')) return { body: { widerrufe: [widerruf(1, erledigt ? { bearbeitetAt: '2026-10-06T08:00:00.000Z' } : {})], seite: 1, mehr: false } }
return { body: liste([]) }
})
try {
const { panel, ctx } = await zeige(fx)
await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
await fx.ruhe()
await fx.klick(aktion(fx, panel, 'erledigt'))
await fx.ruhe()
assert.deepEqual(posts(fx).map((p) => [p.url, p.body]), [['/api/shop/widerrufe/w1/erledigt', { projectId: 'p1' }]])
assert.equal(letzteMeldung(ctx).text, 'Der Widerruf ist als erledigt markiert.')
assert.equal(gets(fx).filter((u) => u.startsWith('/api/shop/widerrufe?')).length, 2)
assert.match(fx.text(panel), /erledigt am 06\.10\.2026/)
assert.equal(aktion(fx, panel, 'erledigt'), null)
} finally {
fx.restore()
}
})
test('Widerruf: „Zur Bestellung“ öffnet das Detail; leere Liste und Blättern', mit((a) => {
if (a.url.startsWith('/api/shop/widerrufe?')) {
const seite = Number(/seite=(\d+)/.exec(a.url)[1])
return { body: { widerrufe: seite === 1 ? [widerruf(1)] : [], seite, mehr: seite === 1 } }
}
return server()(a)
}, async (fx) => {
const { panel } = await zeige(fx)
await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
await fx.ruhe()
await fx.klick(aktion(fx, panel, 'weiter'))
await fx.ruhe()
assert.ok(fx.finde(panel, '[data-leer]'))
await fx.klick(aktion(fx, panel, 'zurueck'))
await fx.ruhe()
await fx.klick(aktion(fx, panel, 'zur-bestellung'))
await fx.ruhe()
assert.equal(gets(fx).at(-1), '/api/shop/bestellungen/o1?projectId=p1')
assert.ok(fx.text(panel).includes('Bestellung WK-1'))
}))
test('Wechsel zwischen den Ansichten: die Liste lädt neu und der Knopf zeigt die Ansicht', mit(server(), async (fx) => {
const { panel } = await zeige(fx)
await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
await fx.ruhe()
assert.ok(fx.finde(panel, '[data-widerruf="w1"]'))
await fx.klick(fx.finde(panel, '[data-ansicht="bestellungen"]'))
await fx.ruhe()
assert.equal(fx.finde(panel, '[data-widerruf="w1"]'), null)
assert.ok(fx.finde(panel, '[data-bestellung="o1"]'))
assert.equal(fx.finde(panel, '[data-ansicht="bestellungen"]').getAttribute('aria-pressed'), 'true')
}))
/* ------------------------------------------------------------- nur lesen --- */
test('nurLesen: keine Aktionen, kein Erstatten, kein „erledigt“; Lesen und CSV bleiben', mit(server(), async (fx) => {
const { panel } = await oeffneDetail(fx, { nurLesen: true })
assert.ok(fx.text(panel).includes('Bestellung WK-1'))
for (const name of ['versendet', 'abgeschlossen', 'erstatten', 'abholbereit', 'storniert']) assert.equal(aktion(fx, panel, name), null, name)
assert.equal(fx.finde(panel, 'form[data-form="erstatten"]'), null)
await fx.klick(aktion(fx, panel, 'liste'))
await fx.ruhe()
assert.ok(fx.finde(panel, '[data-link="csv"]'))
await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
await fx.ruhe()
assert.equal(aktion(fx, panel, 'erledigt'), null)
assert.ok(fx.finde(panel, '[data-widerruf="w1"]'))
assert.equal(posts(fx).length, 0)
}))
test('abwickeln (Shop abgeschaltet): Aktionen, Erstatten und „erledigt“ bleiben, obwohl nurLesen gilt', mit(server(), async (fx) => {
const { panel } = await oeffneDetail(fx, { nurLesen: true, abwickeln: true })
assert.ok(aktion(fx, panel, 'versendet') || aktion(fx, panel, 'abgeschlossen') || fx.finde(panel, 'form[data-form="erstatten"]'))
await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
await fx.ruhe()
assert.ok(aktion(fx, panel, 'erledigt'))
}))
test('Antwort nach dem Wechsel des Reiters zeichnet nicht in das fremde Panel', mit(server(), async (fx) => {
const ctx = baueCtx()
const panel = h('div')
fx.document.body.append(panel)
const fertig = render(panel, ctx)
panel.replaceChildren(h('p', null, 'anderer Reiter'))
await fertig
await fx.ruhe()
assert.equal(fx.text(panel), 'anderer Reiter')
}))
/* ---------------------------------------------------------------- Quelle --- */
test('Quelltext: kein innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, confirm()', () => {
const quelle = readFileSync(new URL('../public/shop-tab-bestellungen.js', import.meta.url), 'utf8')
for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'confirm(']) assert.ok(!quelle.includes(verboten), verboten)
})
+359
View File
@@ -0,0 +1,359 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { h, request } from '../public/shop-dom.js'
import { ANBIETER_HINWEIS, PAYPAL_HINWEIS, SEPA_WARNUNG, STRIPE_HINWEIS, render } from '../public/shop-tab-einstellungen.js'
// Task G6: Reiter „Einstellungen“ mit Fake-DOM und Fake-fetch. Die Antworten des Servers sind von Hand gebaut
// (Form: server/routes/shop.js und server/services/shop/einstellungen.js).
const STANDARD = {
shopname: '',
anbieter: { name: '', strasse: '', plz: '', ort: '', land: 'DE', email: '', telefon: '', ustId: '' },
kleinunternehmer: null,
versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null },
abholung: { aktiv: false, adresse: '', hinweis: '' },
lieferzeit: '',
zahlungsarten: ['card'],
akzent: '#111111',
mindestbestellCent: null,
ruecksendekosten: 'kaeufer',
}
const GEFUELLT = {
shopname: 'Mützenlädchen',
anbieter: { name: 'Erika Muster', strasse: 'Hauptstraße 1', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'erika@example.com', telefon: '0631 123456', ustId: 'DE123456789' },
kleinunternehmer: false,
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 },
abholung: { aktiv: true, adresse: 'Hinterhof 2\n67655 Kaiserslautern', hinweis: 'Mo bis Fr 10 bis 18 Uhr' },
lieferzeit: '1–2 Werktage',
zahlungsarten: ['card', 'klarna', 'sepa_debit'],
akzent: '#ff8800',
mindestbestellCent: 1500,
ruecksendekosten: 'haendler',
}
const ANTWORT = (einstellungen, version = 3) => ({ einstellungen, version, vollstaendig: true })
/** Fake-Server: GET /api/shop/einstellungen antwortet aus `welt.get()`, PUT aus `welt.put(aufruf)` (Standard: Echo mit Version + 1). */
function startFx(welt) {
const route = (aufruf) => {
if (aufruf.method === 'GET' && aufruf.url.startsWith('/api/shop/einstellungen?projectId=')) return { body: typeof welt.get === 'function' ? welt.get() : welt.get }
if (aufruf.method === 'PUT' && aufruf.url === '/api/shop/einstellungen') {
if (welt.put) return welt.put(aufruf)
return { body: ANTWORT(aufruf.body.einstellungen, aufruf.body.version + 1) }
}
return { status: 404, body: { error: 'unbekannt' } }
}
return installFakeDom({ antworten: Array.from({ length: 40 }, () => route) })
}
const mitDom = (welt, fn) => async () => {
const fx = startFx(welt)
try {
await fn(fx)
} finally {
fx.restore()
}
}
function ctxVon(extra = {}) {
const log = { meldungen: [], neuGeladen: 0 }
const ctx = {
projectId: 'p1',
nurLesen: false,
request,
zeigeMeldung: (text, art) => log.meldungen.push([text, art]),
neuLaden: async () => { log.neuGeladen += 1 },
...extra,
}
return { ctx, log }
}
async function oeffne(fx, ctxExtra) {
const root = h('div', { id: 'panel' })
fx.document.body.append(root)
const { ctx, log } = ctxVon(ctxExtra)
await render(root, ctx)
await fx.ruhe()
return { root, ctx, log }
}
const feldVon = (root, name) => root.querySelector(`[data-feld="${name}"]`)
// Steuerart: zwei Radiobuttons ohne Vorauswahl
const steuerRadio = (root, wert) => root.querySelector(`[data-feld="kleinunternehmer"][value="${wert}"]`)
const fehlerVon = (root, name) => root.querySelector(`[data-fehler="${name}"]`)
const speichernKnopf = (root) => root.querySelector('[data-aktion="einstellungen-speichern"]')
const puts = (fx) => fx.fetchLog.filter((a) => a.method === 'PUT')
/* --------------------------------------------------------------- Anzeige --- */
test('Standardwerte: Felder gefüllt, Länder nur DE, Karte fest, Hinweise zu Klarna, Lastschrift und PayPal', mitDom({ get: ANTWORT(STANDARD, 0) }, async (fx) => {
const { root } = await oeffne(fx)
assert.equal(fx.fetchLog[0].url, '/api/shop/einstellungen?projectId=p1')
for (const titel of ['Shop', 'Anbieter', 'Steuer', 'Versand', 'Abholung', 'Zahlungsarten']) {
assert.ok(fx.finde(root, (el) => el.tagName === 'H3' && el.textContent === titel), titel)
}
assert.equal(feldVon(root, 'shopname').value, '')
assert.equal(feldVon(root, 'akzent').type, 'color')
assert.equal(feldVon(root, 'akzent').value, '#111111')
assert.equal(feldVon(root, 'anbieter.land').value, 'DE')
// I-4: keine Vorauswahl bei der Steuerart, Lieferzeit leer
assert.equal(steuerRadio(root, 'ja').type, 'radio')
assert.equal(steuerRadio(root, 'nein').type, 'radio')
assert.equal(steuerRadio(root, 'ja').checked, false)
assert.equal(steuerRadio(root, 'nein').checked, false)
assert.equal(steuerRadio(root, 'ja').getAttribute('name'), steuerRadio(root, 'nein').getAttribute('name'))
assert.equal(feldVon(root, 'versand.aktiv').checked, true)
assert.equal(feldVon(root, 'versand.laender').value, 'DE')
assert.equal(feldVon(root, 'versand.kostenCent.DE').value, '4,90')
assert.equal(feldVon(root, 'versand.kostenCent.DE').type, 'text', 'Betrag als Text, Komma bleibt erlaubt')
assert.equal(feldVon(root, 'versand.freiAbCent').value, '')
assert.equal(feldVon(root, 'lieferzeit').value, '')
assert.equal(feldVon(root, 'mindestbestellCent').value, '')
assert.equal(feldVon(root, 'ruecksendekosten').value, 'kaeufer')
assert.equal(fx.text(root).includes(ANBIETER_HINWEIS), true)
assert.equal(fx.text(root).includes(SEPA_WARNUNG), true)
assert.equal(fx.text(root).includes(STRIPE_HINWEIS), true)
assert.equal(fx.text(root).includes(PAYPAL_HINWEIS), true)
assert.match(SEPA_WARNUNG, /^Bei Lastschrift ist das Geld erst nach einigen Tagen sicher\. Versende erst, wenn die Bestellung als bezahlt markiert ist\.$/)
assert.match(fx.text(root), /Ich bin Kleinunternehmer nach § 19 UStG/)
assert.match(fx.text(root), /Ich weise Umsatzsteuer aus/)
// Österreich und Abholung sind zu: ihre Felder sind versteckt
assert.equal(feldVon(root, 'versand.kostenCent.AT').closest('.sh-versand-at').hidden, true)
assert.equal(feldVon(root, 'abholung.adresse').closest('.sh-abhol-details').hidden, true)
}))
test('Zahlungsarten: Karte immer gesetzt und gesperrt, Klarna und Lastschrift frei wählbar', mitDom({ get: ANTWORT(STANDARD) }, async (fx) => {
const { root } = await oeffne(fx)
const karte = feldVon(root, 'zahlung.card')
assert.equal(karte.checked, true)
assert.equal(karte.disabled, true)
assert.match(fx.text(root), /inklusive Apple Pay und Google Pay/)
assert.equal(feldVon(root, 'zahlung.klarna').checked, false)
assert.equal(feldVon(root, 'zahlung.klarna').disabled, false)
assert.equal(feldVon(root, 'zahlung.sepa_debit').checked, false)
await fx.klick(karte)
assert.equal(karte.checked, true, 'ein Klick ändert nichts')
}))
test('gespeicherte Werte werden angezeigt, Österreich und Abholung sind sichtbar', mitDom({ get: ANTWORT(GEFUELLT) }, async (fx) => {
const { root } = await oeffne(fx)
assert.equal(feldVon(root, 'shopname').value, 'Mützenlädchen')
assert.equal(feldVon(root, 'akzent').value, '#ff8800')
assert.equal(feldVon(root, 'anbieter.plz').value, '67655')
assert.equal(feldVon(root, 'anbieter.ustId').value, 'DE123456789')
assert.equal(steuerRadio(root, 'nein').checked, true)
assert.equal(steuerRadio(root, 'ja').checked, false)
assert.equal(feldVon(root, 'versand.laender').value, 'DE,AT')
assert.equal(feldVon(root, 'versand.kostenCent.AT').value, '9,90')
assert.equal(feldVon(root, 'versand.kostenCent.AT').closest('.sh-versand-at').hidden, false)
assert.equal(feldVon(root, 'versand.freiAbCent').value, '50,00')
assert.equal(feldVon(root, 'abholung.aktiv').checked, true)
assert.equal(feldVon(root, 'abholung.adresse').value, 'Hinterhof 2\n67655 Kaiserslautern')
assert.equal(feldVon(root, 'abholung.adresse').closest('.sh-abhol-details').hidden, false)
assert.equal(feldVon(root, 'mindestbestellCent').value, '15,00')
assert.equal(feldVon(root, 'ruecksendekosten').value, 'haendler')
assert.equal(feldVon(root, 'zahlung.klarna').checked, true)
assert.equal(feldVon(root, 'zahlung.sepa_debit').checked, true)
}))
test('Schalter blenden Österreich, Versand- und Abholfelder ein und aus', mitDom({ get: ANTWORT(STANDARD) }, async (fx) => {
const { root } = await oeffne(fx)
const at = feldVon(root, 'versand.kostenCent.AT').closest('.sh-versand-at')
await fx.setze(feldVon(root, 'versand.laender'), 'DE,AT')
assert.equal(at.hidden, false)
await fx.setze(feldVon(root, 'versand.laender'), 'DE')
assert.equal(at.hidden, true)
const versandDetails = feldVon(root, 'versand.laender').closest('.sh-versand-details')
await fx.klick(feldVon(root, 'versand.aktiv'))
assert.equal(versandDetails.hidden, true)
const abhol = feldVon(root, 'abholung.adresse').closest('.sh-abhol-details')
await fx.klick(feldVon(root, 'abholung.aktiv'))
assert.equal(abhol.hidden, false)
}))
/* ------------------------------------------------------------- Speichern --- */
test('Steuerart I-4: Radiobuttons schließen sich aus, ohne Wahl wird null gespeichert, mit Wahl true oder false', mitDom({ get: ANTWORT(STANDARD, 1) }, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx)[0].body.einstellungen.kleinunternehmer, null)
await fx.klick(steuerRadio(root, 'ja'))
assert.equal(steuerRadio(root, 'ja').checked, true)
assert.equal(steuerRadio(root, 'nein').checked, false)
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx)[1].body.einstellungen.kleinunternehmer, true)
await fx.klick(steuerRadio(root, 'nein'))
assert.equal(steuerRadio(root, 'ja').checked, false)
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx)[2].body.einstellungen.kleinunternehmer, false)
}))
test('Speichern: Körper mit ganzen Cent, Version, Meldung und neuLaden; der Stand des Servers kommt zurück ins Formular', mitDom({
get: ANTWORT(STANDARD, 2),
put: (aufruf) => ({ body: ANTWORT({ ...aufruf.body.einstellungen, anbieter: { ...aufruf.body.einstellungen.anbieter, email: 'erika@example.com' } }, 3) }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.setze(feldVon(root, 'shopname'), ' Mützenlädchen ')
await fx.setze(feldVon(root, 'akzent'), '#ff8800')
await fx.setze(feldVon(root, 'anbieter.name'), 'Erika Muster')
await fx.setze(feldVon(root, 'anbieter.strasse'), 'Hauptstraße 1')
await fx.setze(feldVon(root, 'anbieter.plz'), '67655')
await fx.setze(feldVon(root, 'anbieter.ort'), 'Kaiserslautern')
await fx.setze(feldVon(root, 'anbieter.email'), 'Erika@Example.com')
await fx.setze(feldVon(root, 'anbieter.ustId'), 'DE123456789')
await fx.klick(steuerRadio(root, 'nein'))
await fx.setze(feldVon(root, 'versand.laender'), 'DE,AT')
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '4,9')
await fx.setze(feldVon(root, 'versand.kostenCent.AT'), '9,90')
await fx.setze(feldVon(root, 'versand.freiAbCent'), '50')
await fx.klick(feldVon(root, 'abholung.aktiv'))
await fx.setze(feldVon(root, 'abholung.adresse'), 'Hinterhof 2')
await fx.setze(feldVon(root, 'abholung.hinweis'), 'Mo bis Fr')
await fx.setze(feldVon(root, 'lieferzeit'), '1–2 Werktage')
await fx.setze(feldVon(root, 'mindestbestellCent'), '15')
await fx.setze(feldVon(root, 'ruecksendekosten'), 'haendler')
await fx.klick(feldVon(root, 'zahlung.klarna'))
await fx.klick(feldVon(root, 'zahlung.sepa_debit'))
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).length, 1)
assert.deepEqual(puts(fx)[0].body, {
projectId: 'p1',
version: 2,
einstellungen: {
shopname: 'Mützenlädchen',
akzent: '#ff8800',
anbieter: { name: 'Erika Muster', strasse: 'Hauptstraße 1', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'Erika@Example.com', telefon: '', ustId: 'DE123456789' },
kleinunternehmer: false,
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 },
abholung: { aktiv: true, adresse: 'Hinterhof 2', hinweis: 'Mo bis Fr' },
lieferzeit: '1–2 Werktage',
mindestbestellCent: 1500,
ruecksendekosten: 'haendler',
zahlungsarten: ['card', 'klarna', 'sepa_debit'],
},
})
assert.deepEqual(log.meldungen, [['Die Einstellungen sind gespeichert.', 'ok']])
assert.equal(log.neuGeladen, 1)
assert.equal(feldVon(root, 'anbieter.email').value, 'erika@example.com', 'normalisierter Stand des Servers')
// zweites Speichern schickt die neue Version
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx)[1].body.version, 3)
}))
test('Speichern ohne Österreich schickt nur Deutschland; ohne Versand dürfen die Kosten leer sein', mitDom({ get: ANTWORT({ ...STANDARD, versand: { ...STANDARD.versand, aktiv: false }, abholung: { aktiv: true, adresse: 'Laden', hinweis: '' } }) }, async (fx) => {
const { root } = await oeffne(fx)
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '')
await fx.klick(speichernKnopf(root))
const e = puts(fx)[0].body.einstellungen
assert.deepEqual(e.versand, { aktiv: false, laender: ['DE'], kostenCent: { DE: null }, freiAbCent: null })
assert.deepEqual(e.zahlungsarten, ['card'])
assert.equal(e.mindestbestellCent, null)
}))
test('unlesbare oder fehlende Beträge werden im Browser gemeldet, es geht nichts an den Server', mitDom({ get: ANTWORT(STANDARD) }, async (fx) => {
const { root } = await oeffne(fx)
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), 'viel')
await fx.setze(feldVon(root, 'mindestbestellCent'), '1,234')
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).length, 0)
assert.match(fehlerVon(root, 'versand.kostenCent.DE').textContent, /Betrag wie 4,90/)
assert.match(fehlerVon(root, 'mindestbestellCent').textContent, /Betrag wie 4,90/)
assert.equal(feldVon(root, 'versand.kostenCent.DE').getAttribute('aria-invalid'), 'true')
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '')
await fx.setze(feldVon(root, 'mindestbestellCent'), '')
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).length, 0)
assert.match(fehlerVon(root, 'versand.kostenCent.DE').textContent, /kostenlosen Versand trage 0,00 ein/)
assert.equal(fehlerVon(root, 'mindestbestellCent').textContent, '')
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '0')
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).length, 1)
assert.equal(puts(fx)[0].body.einstellungen.versand.kostenCent.DE, 0)
}))
test('Feldfehler des Servers stehen am Feld, Unbekanntes allgemein; der Knopf ist danach wieder frei', mitDom({
get: ANTWORT(STANDARD),
put: () => ({ status: 400, body: { error: 'Die Einstellungen sind nicht gültig.', fehler: { 'anbieter.plz': 'Bitte gib eine deutsche Postleitzahl mit 5 Ziffern an.', 'versand.aktiv': 'Bitte biete Versand oder Abholung an.', 'dings.unbekannt': 'Etwas stimmt nicht.' } } }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.klick(speichernKnopf(root))
assert.equal(fehlerVon(root, 'anbieter.plz').textContent, 'Bitte gib eine deutsche Postleitzahl mit 5 Ziffern an.')
assert.equal(feldVon(root, 'anbieter.plz').getAttribute('aria-invalid'), 'true')
assert.equal(fehlerVon(root, 'versand.aktiv').textContent, 'Bitte biete Versand oder Abholung an.')
assert.equal(fehlerVon(root, '_').textContent, 'Etwas stimmt nicht.')
assert.equal(speichernKnopf(root).disabled, false)
assert.deepEqual(log.meldungen, [])
assert.equal(log.neuGeladen, 0)
}))
test('409 (Einstellungen veraltet): Hinweis mit „Neu laden“, das den neuen Stand holt', async () => {
let stand = ANTWORT(STANDARD, 1)
const fx = startFx({
get: () => stand,
put: () => ({ status: 409, body: { error: 'Die Einstellungen wurden inzwischen geändert. Bitte lade die Seite neu.', code: 'EINSTELLUNG_VERALTET' } }),
})
try {
const { root } = await oeffne(fx)
await fx.klick(speichernKnopf(root))
const konflikt = root.querySelector('[data-konflikt]')
assert.ok(konflikt)
assert.match(konflikt.textContent, /inzwischen geändert/)
assert.equal(fehlerVon(root, '_').textContent, '')
stand = ANTWORT({ ...STANDARD, shopname: 'Von anderer Seite' }, 2)
await fx.klick(root.querySelector('[data-aktion="neu-laden"]'))
await fx.ruhe()
assert.equal(feldVon(root, 'shopname').value, 'Von anderer Seite')
assert.equal(root.querySelector('[data-konflikt]'), null)
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).at(-1).body.version, 2)
} finally {
fx.restore()
}
})
test('Netzwerkfehler beim Speichern: Meldung allgemein, nichts gespeichert', mitDom({
get: ANTWORT(STANDARD),
put: () => ({ status: 500, body: { error: 'Der Server hat Probleme.' } }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.klick(speichernKnopf(root))
assert.equal(fehlerVon(root, '_').textContent, 'Der Server hat Probleme.')
assert.deepEqual(log.meldungen, [])
}))
/* -------------------------------------------------------------- nur lesen --- */
test('nurLesen: alle Felder gesperrt, kein Speichern-Knopf, kein Versand', mitDom({ get: ANTWORT(GEFUELLT) }, async (fx) => {
const { root } = await oeffne(fx, { nurLesen: true })
const steuerungen = fx.alle(root, (el) => ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName))
assert.ok(steuerungen.length > 20)
assert.deepEqual(steuerungen.filter((el) => !el.disabled).map((el) => el.dataset.feld), [])
assert.equal(speichernKnopf(root), null)
assert.equal(fx.alle(root, (el) => el.tagName === 'BUTTON').filter((b) => b.getAttribute('type') === 'submit').length, 0)
assert.equal(feldVon(root, 'shopname').value, 'Mützenlädchen', 'Werte bleiben sichtbar')
// auch ein abgesendetes Formular schreibt nichts
await fx.sende(root.querySelector('form'), 'submit')
assert.equal(puts(fx).length, 0)
}))
test('Ladefehler wird geworfen (die Hülle zeigt ihn mit „Noch einmal versuchen“)', async () => {
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Kaputt.' } }] })
try {
const root = h('div')
await assert.rejects(render(root, ctxVon().ctx), /Kaputt\./)
} finally {
fx.restore()
}
})
/* ---------------------------------------------------------------- statisch --- */
test('statisch: kein innerHTML, outerHTML, insertAdjacentHTML, document.write und kein Import aus termine*.js', () => {
const quelle = readFileSync(new URL('../public/shop-tab-einstellungen.js', import.meta.url), 'utf8')
const ohneKommentare = quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
assert.doesNotMatch(ohneKommentare, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|confirm\(/)
assert.doesNotMatch(ohneKommentare, /from ['"]\.\/termine/)
})
+371
View File
@@ -0,0 +1,371 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { h, request } from '../public/shop-dom.js'
import { GEWAEHRLEISTUNG_NOTIZ, INTRO, NICHT_BESTAETIGT, render } from '../public/shop-tab-rechtliches.js'
// Task G6: Reiter „Rechtliches“ mit Fake-DOM und Fake-fetch. Die Antworten sind von Hand gebaut (Form: rechtstexteAntwort in server/routes/shop.js).
const BESTAETIGUNG = 'Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.'
const MUSTER = { agb: 'MUSTER-AGB\nZeile zwei', widerrufsbelehrung: 'MUSTER-WIDERRUF', widerrufsformular: 'MUSTER-FORMULAR' }
const VOLL = {
agb: 'Meine AGB', widerrufsbelehrung: 'Meine Belehrung', widerrufsformular: 'Mein Formular',
impressumUrl: 'https://example.com/impressum', datenschutzUrl: 'https://example.com/datenschutz',
}
const LEER = { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' }
const antwort = (entwurf, extra = {}) => ({
entwurf,
bestaetigt: null,
muster: MUSTER,
versandUndZahlung: 'Versand nach Deutschland: 4,90 €.\nZahlung per Karte.',
gewaehrleistung: { bild: '/img/gewaehrleistung-eu.webp', text: 'GESETZLICHE GEWÄHRLEISTUNG\n\nMindestens zwei Jahre.' },
hash: 'hash-1',
bestaetigungText: BESTAETIGUNG,
vollstaendig: Object.values(entwurf).every((w) => w.trim() !== ''),
...extra,
})
const bestaetigt = (aktuell, extra = {}) => ({ texte: VOLL, at: '2026-10-05T12:30:00.000Z', von: 'Erika Muster', aktuell, ...extra })
/** Fake-Server: GET/PUT /api/shop/rechtstexte und POST …/bestaetigen aus `welt` (get(), put(aufruf), post(aufruf)). */
function startFx(welt) {
const route = (aufruf) => {
if (aufruf.method === 'GET' && aufruf.url.startsWith('/api/shop/rechtstexte?projectId=')) return { body: welt.get() }
if (aufruf.method === 'PUT' && aufruf.url === '/api/shop/rechtstexte') return welt.put(aufruf)
if (aufruf.method === 'POST' && aufruf.url === '/api/shop/rechtstexte/bestaetigen') return welt.post(aufruf)
return { status: 404, body: { error: 'unbekannt' } }
}
return installFakeDom({ antworten: Array.from({ length: 40 }, () => route) })
}
const mitDom = (welt, fn) => async () => {
const fx = startFx(welt)
try {
await fn(fx)
} finally {
fx.restore()
}
}
async function oeffne(fx, ctxExtra = {}) {
const root = h('div', { id: 'panel' })
fx.document.body.append(root)
const log = { meldungen: [], neuGeladen: 0 }
const ctx = {
projectId: 'p1',
nurLesen: false,
request,
zeigeMeldung: (text, art) => log.meldungen.push([text, art]),
neuLaden: async () => { log.neuGeladen += 1 },
...ctxExtra,
}
await render(root, ctx)
await fx.ruhe()
return { root, log }
}
const text = (root, key) => root.querySelector(`[data-text="${key}"]`)
const knopf = (root, aktion) => root.querySelector(`[data-aktion="${aktion}"]`)
const haken = (root) => root.querySelector('[data-feld="bestaetigung"]')
const sendet = (fx, art) => fx.fetchLog.filter((a) => a.method === art)
/* --------------------------------------------------------------- Anzeige --- */
test('Anzeige: Hinweis, Texte aus dem Entwurf, erzeugte Texte nur lesbar, Gewährleistungsbild mit Hinweis', mitDom({ get: () => antwort(VOLL) }, async (fx) => {
const { root } = await oeffne(fx)
assert.equal(fx.fetchLog[0].url, '/api/shop/rechtstexte?projectId=p1')
assert.equal(fx.text(root.querySelector('[data-hinweis="intro"]')), INTRO)
assert.match(INTRO, /^Du bist Verkäufer und für deine Rechtstexte verantwortlich\. Wir stellen Muster bereit, die du prüfen und anpassen musst – das ist keine Rechtsberatung\. Lass deine Texte am besten von einer Kanzlei oder einem Händlerbund prüfen\.$/)
assert.equal(text(root, 'agb').value, 'Meine AGB')
assert.equal(text(root, 'widerrufsbelehrung').value, 'Meine Belehrung')
assert.equal(text(root, 'widerrufsformular').value, 'Mein Formular')
assert.equal(text(root, 'impressumUrl').value, 'https://example.com/impressum')
assert.equal(text(root, 'datenschutzUrl').value, 'https://example.com/datenschutz')
for (const titel of ['Versand und Zahlung', 'Hinweis zur gesetzlichen Gewährleistung', 'Muster-Widerrufsformular']) assert.ok(fx.text(root).includes(titel), titel)
const versand = root.querySelector('[data-lesetext="versand-zahlung"]')
assert.equal(versand.textContent, 'Versand nach Deutschland: 4,90 €.\nZahlung per Karte.')
assert.equal(versand.tagName === 'TEXTAREA' || versand.tagName === 'INPUT', false, 'nur lesbar, kein Eingabefeld')
const bild = root.querySelector('img')
assert.equal(bild.getAttribute('src'), '/img/gewaehrleistung-eu.webp')
assert.match(bild.getAttribute('alt'), /GESETZLICHE GEWÄHRLEISTUNG/)
assert.equal(root.querySelector('[data-lesetext="gewaehrleistung"]').textContent.startsWith('GESETZLICHE GEWÄHRLEISTUNG'), true)
assert.equal(fx.text(root).includes(GEWAEHRLEISTUNG_NOTIZ), true)
assert.match(GEWAEHRLEISTUNG_NOTIZ, /automatisch vor dem Bestell-Knopf/)
assert.equal(fx.alle(root, (el) => el.tagName === 'TEXTAREA').length, 3)
}))
test('ein Gewährleistungsbild von fremder Adresse wird nicht geladen', mitDom({ get: () => antwort(VOLL, { gewaehrleistung: { bild: '//boese.example/x.webp', text: 'Text' } }) }, async (fx) => {
const { root } = await oeffne(fx)
assert.equal(root.querySelector('img'), null)
}))
/* ------------------------------------------------------------ Status, Banner --- */
test('Status: noch nie bestätigt, aktuell bestätigt (Datum und Person) und Banner bei unbestätigten Änderungen', async () => {
const fall = [
[null, 'offen', /Noch nicht bestätigt/],
[bestaetigt(true), 'bestaetigt', /Bestätigt am 05\.10\.2026,? 14:30 Uhr von Erika Muster\./],
[bestaetigt(false), 'unbestaetigt', /Änderungen noch nicht bestätigt.*zuletzt bestätigt am 05\.10\.2026,? 14:30 Uhr von Erika Muster/],
]
for (const [b, art, muster] of fall) {
const fx = startFx({ get: () => antwort(VOLL, { bestaetigt: b }) })
try {
const { root } = await oeffne(fx)
const status = root.querySelector('[data-status]')
assert.equal(status.dataset.status, art)
assert.match(status.textContent, muster)
assert.equal(root.querySelector('[data-status="unbestaetigt"]') !== null, art === 'unbestaetigt')
// schon bestätigt und nichts geändert: kein Bestätigungsblock
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, art === 'bestaetigt')
} finally {
fx.restore()
}
}
assert.equal(NICHT_BESTAETIGT, 'Änderungen noch nicht bestätigt')
})
/* -------------------------------------------------------- Muster einsetzen --- */
test('Muster einsetzen: leeres Feld wird sofort gefüllt, ein vorhandener Text erst nach Rückfrage', mitDom({ get: () => antwort({ ...LEER, widerrufsbelehrung: 'Mein eigener Text' }) }, async (fx) => {
const { root } = await oeffne(fx)
// leer: sofort
await fx.klick(knopf(root, 'muster-agb'))
assert.equal(text(root, 'agb').value, 'MUSTER-AGB\nZeile zwei')
assert.equal(root.querySelector('[data-frage-platz="agb"]').children.length, 0)
// vorhandener Text: Rückfrage, Abbrechen lässt ihn stehen
await fx.klick(knopf(root, 'muster-widerrufsbelehrung'))
const platz = root.querySelector('[data-frage-platz="widerrufsbelehrung"]')
assert.match(platz.textContent, /Widerrufsbelehrung durch das Muster ersetzen\?/)
assert.equal(text(root, 'widerrufsbelehrung').value, 'Mein eigener Text')
await fx.klick(knopf(root, 'muster-nein'))
assert.equal(text(root, 'widerrufsbelehrung').value, 'Mein eigener Text')
assert.equal(platz.children.length, 0)
// und mit „Ja“ wird ersetzt
await fx.klick(knopf(root, 'muster-widerrufsbelehrung'))
await fx.klick(knopf(root, 'muster-ja'))
assert.equal(text(root, 'widerrufsbelehrung').value, 'MUSTER-WIDERRUF')
assert.equal(platz.children.length, 0)
// Formular: leer ⇒ Muster
await fx.klick(knopf(root, 'muster-widerrufsformular'))
assert.equal(text(root, 'widerrufsformular').value, 'MUSTER-FORMULAR')
assert.equal(sendet(fx, 'PUT').length, 0, 'Muster einsetzen speichert nichts')
}))
test('Muster einsetzen: ist der Text schon das Muster, braucht es keine Rückfrage', mitDom({ get: () => antwort({ ...LEER, agb: 'MUSTER-AGB\nZeile zwei' }) }, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(knopf(root, 'muster-agb'))
assert.equal(root.querySelector('[data-frage-platz="agb"]').children.length, 0)
}))
/* ------------------------------------------------------------ Entwurf speichern --- */
test('Entwurf speichern: Körper mit fünf Feldern, Antwort füllt die Felder, Meldung', mitDom({
get: () => antwort(LEER),
put: (aufruf) => ({ body: antwort({ ...aufruf.body.rechtstexte, agb: 'Serverfassung der AGB' }, { hash: 'hash-2' }) }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.setze(text(root, 'agb'), ' Meine AGB ')
await fx.setze(text(root, 'widerrufsbelehrung'), 'Meine Belehrung')
await fx.setze(text(root, 'widerrufsformular'), 'Mein Formular')
await fx.setze(text(root, 'impressumUrl'), 'https://example.com/impressum')
await fx.setze(text(root, 'datenschutzUrl'), 'https://example.com/datenschutz')
await fx.klick(knopf(root, 'entwurf-speichern'))
assert.deepEqual(sendet(fx, 'PUT').map((a) => a.body), [{ projectId: 'p1', rechtstexte: { ...VOLL, agb: 'Meine AGB' } }])
assert.equal(text(root, 'agb').value, 'Serverfassung der AGB')
assert.deepEqual(log.meldungen, [['Der Entwurf ist gespeichert.', 'ok']])
}))
test('Entwurf speichern: Feldfehler des Servers stehen am Feld', mitDom({
get: () => antwort(VOLL),
put: () => ({ status: 400, body: { error: 'Ungültig.', fehler: { impressumUrl: 'Impressum: Die Adresse muss mit https:// beginnen.', agb: 'Die AGB darf höchstens 30000 Zeichen lang sein.' } } }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.setze(text(root, 'impressumUrl'), 'http://example.com/impressum')
await fx.klick(knopf(root, 'entwurf-speichern'))
assert.equal(root.querySelector('[data-fehler="impressumUrl"]').textContent, 'Impressum: Die Adresse muss mit https:// beginnen.')
assert.equal(text(root, 'impressumUrl').getAttribute('aria-invalid'), 'true')
assert.match(root.querySelector('[data-fehler="agb"]').textContent, /höchstens 30000/)
assert.deepEqual(log.meldungen, [])
assert.equal(knopf(root, 'entwurf-speichern').disabled, false)
}))
/* ---------------------------------------------------------------- Bestätigen --- */
test('Bestätigen: Haken nötig, dann POST mit dem Hash aus der letzten Antwort; danach Status bestätigt', async () => {
let aktuell = antwort(VOLL, { hash: 'hash-abc' })
const fx = startFx({
get: () => aktuell,
post: () => {
aktuell = antwort(VOLL, { hash: 'hash-abc', bestaetigt: bestaetigt(true) })
return { body: aktuell }
},
})
try {
const { root, log } = await oeffne(fx)
assert.equal(root.querySelector('[data-block="bestaetigen"]').textContent.includes(BESTAETIGUNG), true)
assert.equal(knopf(root, 'bestaetigen').textContent, 'Texte bestätigen')
assert.equal(knopf(root, 'bestaetigen').disabled, true, 'ohne Haken gesperrt')
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').length, 0)
await fx.klick(haken(root))
assert.equal(knopf(root, 'bestaetigen').disabled, false)
await fx.klick(knopf(root, 'bestaetigen'))
assert.deepEqual(sendet(fx, 'POST').map((a) => [a.url, a.body]), [['/api/shop/rechtstexte/bestaetigen', { projectId: 'p1', hash: 'hash-abc' }]])
assert.equal(root.querySelector('[data-status]').dataset.status, 'bestaetigt')
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, true)
assert.deepEqual(log.meldungen, [['Die Texte sind bestätigt.', 'ok']])
assert.equal(log.neuGeladen, 1)
} finally {
fx.restore()
}
})
test('ungespeicherte Änderungen sperren das Bestätigen: „Erst speichern“, nach dem Speichern gilt der neue Hash', async () => {
const fx = startFx({
get: () => antwort(VOLL, { hash: 'alt', bestaetigt: bestaetigt(true) }),
put: (aufruf) => ({ body: antwort(aufruf.body.rechtstexte, { hash: 'neu', bestaetigt: bestaetigt(false) }) }),
post: () => ({ body: antwort(VOLL, { hash: 'neu', bestaetigt: bestaetigt(true) }) }),
})
try {
const { root } = await oeffne(fx)
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, true)
await fx.setze(text(root, 'agb'), 'Geänderte AGB')
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, false)
assert.equal(knopf(root, 'bestaetigen').textContent, 'Erst speichern')
assert.equal(knopf(root, 'bestaetigen').disabled, true)
assert.match(root.querySelector('[data-hinweis="bestaetigen"]').textContent, /noch nicht gespeichert/)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').length, 0)
// zurückändern: wieder sauber
await fx.setze(text(root, 'agb'), 'Meine AGB')
assert.equal(knopf(root, 'bestaetigen').textContent, 'Texte bestätigen')
await fx.setze(text(root, 'agb'), 'Geänderte AGB')
await fx.klick(knopf(root, 'entwurf-speichern'))
assert.equal(knopf(root, 'bestaetigen').textContent, 'Texte bestätigen')
assert.equal(root.querySelector('[data-status]').dataset.status, 'unbestaetigt')
assert.equal(haken(root).checked, false, 'nach neuen Texten muss der Haken neu gesetzt werden')
assert.equal(knopf(root, 'bestaetigen').disabled, true)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.deepEqual(sendet(fx, 'POST').map((a) => a.body), [{ projectId: 'p1', hash: 'neu' }])
} finally {
fx.restore()
}
})
test('I-5: Probleme des Servers (Platzhalter, Musterkopf, Adresse der Widerrufsfunktion) stehen im Bestätigungsblock und sperren den Knopf', async () => {
const probleme = ['In den AGB stehen noch Platzhalter in eckigen Klammern: „[Name]“.', 'Die Widerrufsbelehrung muss die Adresse deiner Widerrufsfunktion enthalten: https://x.example/w.']
const fx = startFx({ get: () => antwort(VOLL, { hash: 'h1', probleme }), post: () => ({ body: antwort(VOLL) }) })
try {
const { root } = await oeffne(fx)
const box = root.querySelector('[data-probleme]')
assert.ok(box, 'Liste sichtbar')
assert.deepEqual([...box.querySelectorAll('li')].map((li) => li.textContent), probleme)
await fx.klick(haken(root))
assert.equal(knopf(root, 'bestaetigen').disabled, true, 'mit Problemen gesperrt')
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').length, 0)
} finally {
fx.restore()
}
})
test('I-5: lehnt der Server mit nicht_bestaetigbar ab, zeigt der Reiter dessen Liste', async () => {
const probleme = ['In den AGB steht noch die Zeile „Muster – bitte prüfen und anpassen“. Prüfe den Text, passe ihn an und lösche die Zeile.']
const fx = startFx({
get: () => antwort(VOLL, { hash: 'h1' }),
post: () => ({ status: 400, body: { error: 'Die Texte können noch nicht bestätigt werden. Bitte behebe zuerst die genannten Punkte.', code: 'nicht_bestaetigbar', probleme } }),
})
try {
const { root } = await oeffne(fx)
assert.equal(root.querySelector('[data-probleme]'), null)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').length, 1)
assert.deepEqual([...root.querySelector('[data-probleme]').querySelectorAll('li')].map((li) => li.textContent), probleme)
assert.equal(knopf(root, 'bestaetigen').disabled, true)
} finally {
fx.restore()
}
})
test('unvollständige Texte lassen sich nicht bestätigen und sagen warum', mitDom({ get: () => antwort({ ...VOLL, datenschutzUrl: '' }) }, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(haken(root))
assert.equal(knopf(root, 'bestaetigen').disabled, true)
assert.match(root.querySelector('[data-hinweis="bestaetigen"]').textContent, /ausgefüllt und gespeichert/)
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').length, 0)
}))
test('409 beim Bestätigen (Texte inzwischen geändert): Meldung, neuer Stand, Haken wieder frei', async () => {
let stand = antwort(VOLL, { hash: 'alt' })
const fx = startFx({
get: () => stand,
post: () => {
stand = antwort({ ...VOLL, agb: 'Von anderer Seite geändert' }, { hash: 'neu' })
return { status: 409, body: { error: 'Die Texte wurden inzwischen geändert. Bitte lies sie noch einmal durch und bestätige dann erneut.', code: 'texte_geaendert' } }
},
})
try {
const { root } = await oeffne(fx)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.match(root.querySelector('[data-fehler="bestaetigung"]').textContent, /inzwischen geändert/)
assert.equal(text(root, 'agb').value, 'Von anderer Seite geändert')
assert.equal(haken(root).checked, false)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').at(-1).body.hash, 'neu')
} finally {
fx.restore()
}
})
test('Fehler beim Bestätigen (zum Beispiel Kundenansicht, 403) steht am Block', mitDom({
get: () => antwort(VOLL),
post: () => ({ status: 403, body: { error: 'In der Kundenansicht kannst Du nichts bestätigen.' } }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(root.querySelector('[data-fehler="bestaetigung"]').textContent, 'In der Kundenansicht kannst Du nichts bestätigen.')
assert.deepEqual(log.meldungen, [])
assert.equal(sendet(fx, 'GET').length, 1, 'bei 403 wird nicht neu geladen')
}))
/* -------------------------------------------------------------- nur lesen --- */
test('nurLesen: Felder gesperrt, keine Knöpfe, kein Bestätigungsblock; Status und Lesetexte bleiben', mitDom({ get: () => antwort(VOLL, { bestaetigt: bestaetigt(false) }) }, async (fx) => {
const { root } = await oeffne(fx, { nurLesen: true })
const steuerungen = fx.alle(root, (el) => ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName))
assert.equal(steuerungen.length, 5)
assert.deepEqual(steuerungen.filter((el) => !el.disabled), [])
assert.equal(fx.alle(root, (el) => el.tagName === 'BUTTON').length, 0)
assert.equal(haken(root), null)
assert.equal(text(root, 'agb').value, 'Meine AGB')
assert.ok(root.querySelector('[data-status="unbestaetigt"]'))
assert.equal(root.querySelector('[data-lesetext="versand-zahlung"]').textContent.includes('Zahlung per Karte'), true)
await fx.sende(root.querySelector('form'), 'submit')
assert.equal(sendet(fx, 'PUT').length, 0)
}))
test('Ladefehler wird geworfen', async () => {
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Kaputt.' } }] })
try {
await assert.rejects(render(h('div'), { projectId: 'p1', request }), /Kaputt\./)
} finally {
fx.restore()
}
})
/* ---------------------------------------------------------------- statisch --- */
test('statisch: kein innerHTML, outerHTML, insertAdjacentHTML, document.write, confirm() und kein Import aus termine*.js', () => {
const quelle = readFileSync(new URL('../public/shop-tab-rechtliches.js', import.meta.url), 'utf8')
const ohneKommentare = quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
assert.doesNotMatch(ohneKommentare, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|confirm\(/)
assert.doesNotMatch(ohneKommentare, /from ['"]\.\/termine/)
})
+414
View File
@@ -0,0 +1,414 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { h, request } from '../public/shop-dom.js'
import { render } from '../public/shop-tab-uebersicht.js'
// Task G3: Reiter „Übersicht“ (public/shop-tab-uebersicht.js) mit Fake-DOM und Fake-fetch. Die Antworten sind von Hand gebaut
// (Form: server/routes/shop.js, GET /status, POST /oeffnen, POST /stripe/einrichten|link|aktualisieren).
const SNIPPET = '<div data-webklar-shop="abc123"></div>\n<script src="https://project.webklar.com/embed/shop.js" defer></script>'
const VORSCHAU = 'https://project.webklar.com/shop/abc123'
const URL_STRIPE = 'https://connect.stripe.test/setup/e/acct_1/xyz'
const punkte = (...oks) => oks.map((ok, i) => ({ id: `p${i}`, ok, text: `Punkt ${i} ${ok ? 'erledigt' : 'fehlt'}` }))
const bereitschaft = (...oks) => ({ bereit: oks.every(Boolean), punkte: punkte(...oks) })
const STRIPE_OK = { charges_enabled: true, payouts_enabled: true, details_submitted: true, currently_due: [], past_due: [], disabled_reason: null }
const status = (extra = {}) => ({
verfuegbar: true, state: 'active', bereitschaft: bereitschaft(true, true, true), geoeffnet: false, schalter: false, siteKey: 'abc123',
snippet: SNIPPET, vorschauUrl: VORSCHAU, stripe: { verbunden: true, status: STRIPE_OK },
zahlen: { artikel: 4, aktiveArtikel: 3, offeneBestellungen: 2, bestellungen30: 11, umsatz30Cent: 123450 }, ...extra,
})
/** Fake-Server: jede Anfrage geht an route(aufruf) ⇒ { status, body }; Standard 200 {}. */
function starte(route = () => ({ body: {} })) {
return installFakeDom({ antworten: Array.from({ length: 40 }, () => route) })
}
/** ctx wie shop.js ihn baut; meldungen sammelt zeigeMeldung, neuLaden gibt `neu` (oder den alten Stand) zurück. */
function baueCtx(st, { nurLesen = false, stripeRueckkehr = '', neu = null } = {}) {
const ctx = {
projectId: 'p1',
status: st,
nurLesen,
stripeRueckkehr,
meldungen: [],
geladen: 0,
aktuell: () => true,
request,
zeigeMeldung: (text, art) => ctx.meldungen.push({ text, art }),
neuLaden: async () => {
ctx.geladen += 1
if (neu) ctx.status = typeof neu === 'function' ? neu() : neu
return ctx.status
},
}
return ctx
}
async function zeige(fx, st, optionen = {}) {
const ctx = baueCtx(st, optionen)
const panel = h('div', { id: 'sh-panel' })
fx.document.body.append(panel)
await render(panel, ctx)
await fx.ruhe()
return { panel, ctx }
}
const mit = (route, fn) => async () => {
const fx = starte(route)
try {
await fn(fx)
} finally {
fx.restore()
}
}
const knopf = (fx, panel, aktion) => fx.finde(panel, `[data-aktion="${aktion}"]`)
const posts = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET')
const karte = (fx, panel, titel) => fx.alle(panel, 'section').find((s) => fx.finde(s, 'h3')?.textContent === titel)
/* ------------------------------------------------------------ Darstellung --- */
test('Übersicht: vier Karten, Checkliste mit Haken und Kreis, Zustand geschlossen', mit(undefined, async (fx) => {
const { panel } = await zeige(fx, status({ bereitschaft: bereitschaft(true, false, true) }))
assert.deepEqual(fx.alle(panel, 'section').map((s) => fx.finde(s, 'h3').textContent), ['Bereit zum Öffnen', 'Stripe-Konto', 'Auf deiner Website', 'Zahlen'])
const eintraege = fx.alle(panel, '[data-punkt]')
assert.equal(eintraege.length, 3)
assert.deepEqual(eintraege.map((li) => li.dataset.ok), ['1', '0', '1'])
assert.match(fx.text(eintraege[0]), /✓/)
assert.match(fx.text(eintraege[1]), /○/)
assert.match(fx.text(eintraege[1]), /Punkt 1 fehlt/)
assert.equal(fx.finde(panel, '[data-zustand]').textContent, 'Dein Shop ist geschlossen.')
}))
test('Übersicht: geöffnet zeigt den Zustand und „Shop schließen“', mit(undefined, async (fx) => {
const { panel } = await zeige(fx, status({ geoeffnet: true, schalter: true }))
assert.equal(fx.finde(panel, '[data-zustand]').textContent, 'Dein Shop ist geöffnet.')
assert.equal(knopf(fx, panel, 'schliessen').textContent, 'Shop schließen')
assert.equal(knopf(fx, panel, 'oeffnen'), null)
}))
test('Übersicht: ohne Bereitschaft (null) gibt es Hinweis, aber keinen Schalter', mit(undefined, async (fx) => {
const { panel } = await zeige(fx, status({ bereitschaft: null, snippet: null, vorschauUrl: null, zahlen: null, stripe: { verbunden: false, status: null } }))
assert.match(fx.text(karte(fx, panel, 'Bereit zum Öffnen')), /Die Checkliste erscheint/)
assert.equal(knopf(fx, panel, 'oeffnen'), null)
assert.equal(knopf(fx, panel, 'schliessen'), null)
assert.ok(!karte(fx, panel, 'Zahlen'), 'ohne Zahlen keine Karte')
assert.match(fx.text(karte(fx, panel, 'Auf deiner Website')), /Der Einbau-Code erscheint/)
}))
test('Stripe nicht verbunden: Erklärung (eigenes Konto, Gebühren, kein Geld über WEBklar, kein PayPal) und „Stripe-Konto einrichten“', mit(undefined, async (fx) => {
const { panel } = await zeige(fx, status({ stripe: { verbunden: false, status: null } }))
const text = fx.text(karte(fx, panel, 'Stripe-Konto'))
assert.match(text, /direkt auf dein eigenes Stripe-Konto/)
assert.match(text, /Stripe-Gebühren zahlst du direkt an Stripe/)
assert.match(text, /WEBklar bekommt kein Geld deiner Käufer/)
assert.match(text, /PayPal ist über Stripe für Shops wie deinen leider nicht möglich/)
assert.equal(knopf(fx, panel, 'stripe-einrichten').textContent, 'Stripe-Konto einrichten')
assert.equal(knopf(fx, panel, 'stripe-link'), null)
assert.equal(fx.finde(panel, '[data-link="dashboard"]'), null)
}))
test('Stripe verbunden und fertig: Zahlungen und Auszahlungen aktiv, keine fehlenden Angaben, drei Aktionen', mit(undefined, async (fx) => {
const { panel } = await zeige(fx, status())
const k = karte(fx, panel, 'Stripe-Konto')
assert.equal(fx.finde(k, '[data-stripe="zahlungen"]').textContent, 'Zahlungen: aktiv')
assert.equal(fx.finde(k, '[data-stripe="auszahlungen"]').textContent, 'Auszahlungen: aktiv')
assert.equal(fx.finde(k, '[data-stripe="fehlend"]').textContent, 'Keine fehlenden Angaben')
assert.equal(knopf(fx, panel, 'stripe-link').textContent, 'Angaben ergänzen')
assert.equal(knopf(fx, panel, 'stripe-aktualisieren').textContent, 'Status aktualisieren')
const dash = fx.finde(panel, '[data-link="dashboard"]')
assert.equal(dash.tagName, 'A')
assert.equal(dash.getAttribute('href'), 'https://dashboard.stripe.com/')
assert.equal(dash.getAttribute('target'), '_blank')
assert.equal(dash.getAttribute('rel'), 'noopener noreferrer')
assert.equal(dash.textContent, 'Stripe-Dashboard öffnen')
assert.equal(knopf(fx, panel, 'stripe-einrichten'), null)
}))
test('Stripe verbunden mit offenen Punkten: Zähler der fehlenden Angaben, Hinweis und Einschränkung', mit(undefined, async (fx) => {
const s = { charges_enabled: false, payouts_enabled: false, details_submitted: false, currently_due: ['a', 'b', 'c'], past_due: ['b'], disabled_reason: 'requirements.past_due', hinweis: 'Klarna ist in deinem Stripe-Konto noch nicht aktiv.' }
const { panel } = await zeige(fx, status({ stripe: { verbunden: true, status: s } }))
const k = karte(fx, panel, 'Stripe-Konto')
assert.equal(fx.finde(k, '[data-stripe="zahlungen"]').textContent, 'Zahlungen: noch nicht freigeschaltet')
assert.equal(fx.finde(k, '[data-stripe="auszahlungen"]').textContent, 'Auszahlungen: noch nicht freigeschaltet')
assert.equal(fx.finde(k, '[data-stripe="fehlend"]').textContent, '3 Angaben fehlen noch', 'überfällige zählen nicht doppelt')
assert.match(fx.finde(k, '[data-stripe="angaben"]').textContent, /noch nicht abgeschickt/)
assert.match(fx.finde(k, '[data-stripe="gesperrt"]').textContent, /requirements\.past_due/)
assert.equal(fx.finde(k, '[data-stripe="hinweis"]').textContent, 'Klarna ist in deinem Stripe-Konto noch nicht aktiv.')
const eins = await zeige(fx, status({ stripe: { verbunden: true, status: { ...STRIPE_OK, currently_due: ['x'] } } }))
assert.equal(fx.finde(eins.panel, '[data-stripe="fehlend"]').textContent, '1 Angabe fehlt noch')
}))
test('Stripe verbunden, Stand noch nicht gelesen (status null): Hinweis statt Zeilen', mit(undefined, async (fx) => {
const { panel } = await zeige(fx, status({ stripe: { verbunden: true, status: null } }))
assert.match(fx.text(karte(fx, panel, 'Stripe-Konto')), /noch nicht gelesen/)
}))
test('Website-Karte: Code nur lesbar im <pre><code>, Vorschau-Link, Satz „WEBklar baut den Shop ein“', mit(undefined, async (fx) => {
const { panel } = await zeige(fx, status())
const k = karte(fx, panel, 'Auf deiner Website')
const code = fx.finde(k, 'pre code')
assert.equal(code.textContent, SNIPPET)
assert.equal(fx.finde(k, 'textarea, input'), null, 'nicht editierbar')
assert.match(fx.text(k), /WEBklar baut den Shop für dich in deine Website ein/)
const vorschau = fx.finde(k, '[data-link="vorschau"]')
assert.equal(vorschau.getAttribute('href'), VORSCHAU)
assert.equal(vorschau.getAttribute('target'), '_blank')
assert.equal(vorschau.getAttribute('rel'), 'noopener noreferrer')
}))
test('Vorschau-Link nur für https-Adressen', mit(undefined, async (fx) => {
for (const schlecht of ['http://project.webklar.com/shop/x', 'javascript:alert(1)', '//evil.example/x', 'https://user:pw@evil.example/']) {
const { panel } = await zeige(fx, status({ vorschauUrl: schlecht }))
assert.equal(fx.finde(panel, '[data-link="vorschau"]'), null, schlecht)
}
}))
test('Zahlen: Artikel, offene Bestellungen, Bestellungen und Umsatz 30 Tage; fehlende Werte fehlen', mit(undefined, async (fx) => {
const { panel } = await zeige(fx, status())
const k = karte(fx, panel, 'Zahlen')
assert.equal(fx.finde(k, '[data-zahl="artikel"] dd').textContent, '4 (davon 3 aktiv)')
assert.equal(fx.finde(k, '[data-zahl="offene"] dd').textContent, '2')
assert.equal(fx.finde(k, '[data-zahl="bestellungen30"] dd').textContent, '11')
assert.equal(fx.finde(k, '[data-zahl="umsatz30"] dd').textContent, '1.234,50\u00a0€')
const teil = await zeige(fx, status({ zahlen: { artikel: 0 } }))
assert.equal(fx.alle(teil.panel, '[data-zahl]').length, 1)
assert.equal(fx.finde(teil.panel, '[data-zahl="artikel"] dd').textContent, '0')
}))
test('Fremdtext (Punkte, Hinweis, Snippet) kommt nur als Text in die Seite', mit(undefined, async (fx) => {
const boese = '<img src=x onerror=alert(1)>'
const st = status({
bereitschaft: { bereit: false, punkte: [{ id: 'x', ok: false, text: boese }] },
stripe: { verbunden: true, status: { ...STRIPE_OK, hinweis: boese } },
snippet: boese,
})
const { panel } = await zeige(fx, st)
assert.equal(fx.alle(panel, 'img').length, 0)
assert.ok(fx.text(panel).includes(boese))
}))
/* ---------------------------------------------------------------- Öffnen --- */
test('„Shop öffnen“ ist gesperrt, solange nicht alles bereit ist, und tut dann nichts', mit(undefined, async (fx) => {
const { panel, ctx } = await zeige(fx, status({ bereitschaft: bereitschaft(true, false, true) }))
const k = knopf(fx, panel, 'oeffnen')
assert.equal(k.textContent, 'Shop öffnen')
assert.equal(k.disabled, true)
await fx.klick(k)
assert.equal(posts(fx).length, 0)
assert.equal(ctx.meldungen.length, 0)
}))
test('„Shop öffnen“ schickt { projectId, offen: true }, zeigt den neuen Zustand und lädt den Stand neu', mit((a) => {
if (a.method === 'POST' && a.url === '/api/shop/oeffnen') return { body: { schalter: true, geoeffnet: true, bereitschaft: bereitschaft(true, true, true) } }
return { body: {} }
}, async (fx) => {
const { panel, ctx } = await zeige(fx, status(), { neu: () => status({ schalter: true, geoeffnet: true }) })
assert.equal(knopf(fx, panel, 'oeffnen').disabled, false)
await fx.klick(knopf(fx, panel, 'oeffnen'))
assert.deepEqual(posts(fx).map((a) => [a.method, a.url, a.body]), [['POST', '/api/shop/oeffnen', { projectId: 'p1', offen: true }]])
assert.equal(fx.finde(panel, '[data-zustand]').textContent, 'Dein Shop ist geöffnet.')
assert.equal(knopf(fx, panel, 'schliessen').textContent, 'Shop schließen')
assert.equal(ctx.geladen, 1)
assert.deepEqual(ctx.meldungen, [{ text: 'Dein Shop ist jetzt geöffnet.', art: 'ok' }])
}))
test('„Shop schließen“ schickt { projectId, offen: false }', mit((a) => {
if (a.url === '/api/shop/oeffnen') return { body: { schalter: false, geoeffnet: false, bereitschaft: bereitschaft(true, true, true) } }
return { body: {} }
}, async (fx) => {
const { panel, ctx } = await zeige(fx, status({ schalter: true, geoeffnet: true }), { neu: () => status() })
await fx.klick(knopf(fx, panel, 'schliessen'))
assert.deepEqual(posts(fx).map((a) => a.body), [{ projectId: 'p1', offen: false }])
assert.equal(fx.finde(panel, '[data-zustand]').textContent, 'Dein Shop ist geschlossen.')
assert.equal(knopf(fx, panel, 'oeffnen').textContent, 'Shop öffnen')
assert.equal(ctx.meldungen[0].text, 'Dein Shop ist jetzt geschlossen.')
}))
test('409 nicht_bereit: die Punkte des Servers ersetzen die Checkliste, der Fehlertext erscheint, der Knopf ist wieder frei zu', mit((a) => {
if (a.url === '/api/shop/oeffnen') return { status: 409, body: { error: 'Der Shop kann noch nicht geöffnet werden. Bitte erledige zuerst die offenen Punkte.', code: 'nicht_bereit', punkte: punkte(true, false, false) } }
return { body: {} }
}, async (fx) => {
const { panel, ctx } = await zeige(fx, status()) // veralteter Stand: alles bereit
await fx.klick(knopf(fx, panel, 'oeffnen'))
assert.deepEqual(fx.alle(panel, '[data-punkt]').map((li) => li.dataset.ok), ['1', '0', '0'])
assert.equal(ctx.meldungen.length, 1)
assert.equal(ctx.meldungen[0].art, 'fehler')
assert.match(ctx.meldungen[0].text, /noch nicht geöffnet werden/)
assert.equal(knopf(fx, panel, 'oeffnen').disabled, true, 'nach dem Fehler stimmt der Stand: nicht bereit')
}))
test('Kundenansicht: 403 des Servers wird als Fehlertext gezeigt', mit((a) => {
if (a.url.startsWith('/api/shop/')) return { status: 403, body: { error: 'In der Kundenansicht kann das nicht geändert werden.' } }
return { body: {} }
}, async (fx) => {
const { panel, ctx } = await zeige(fx, status())
await fx.klick(knopf(fx, panel, 'oeffnen'))
await fx.klick(knopf(fx, panel, 'stripe-aktualisieren'))
assert.deepEqual(ctx.meldungen, [
{ text: 'In der Kundenansicht kann das nicht geändert werden.', art: 'fehler' },
{ text: 'In der Kundenansicht kann das nicht geändert werden.', art: 'fehler' },
])
assert.equal(knopf(fx, panel, 'oeffnen').disabled, false, 'Knöpfe sind nach dem Fehler wieder bedienbar')
}))
/* ---------------------------------------------------------------- Stripe --- */
test('„Stripe-Konto einrichten“ schickt { projectId } und geht nur auf eine https-Adresse', mit((a) => {
if (a.url === '/api/shop/stripe/einrichten') return { body: { url: URL_STRIPE } }
return { body: {} }
}, async (fx) => {
const { panel } = await zeige(fx, status({ stripe: { verbunden: false, status: null } }))
await fx.klick(knopf(fx, panel, 'stripe-einrichten'))
assert.deepEqual(posts(fx).map((a) => [a.url, a.body]), [['/api/shop/stripe/einrichten', { projectId: 'p1' }]])
assert.equal(fx.window.location.href, URL_STRIPE)
}))
test('Adresse von Stripe ohne https: keine Navigation, Fehlertext, Stand wird neu geladen', async () => {
for (const url of ['http://connect.stripe.test/x', 'javascript:alert(1)', '//evil.example', '', undefined, 'https://u:p@evil.example/']) {
const fx = starte((a) => (a.url === '/api/shop/stripe/einrichten' ? { body: { url } } : { body: {} }))
try {
fx.window.location.href = 'http://localhost/dashboard.html'
const { panel, ctx } = await zeige(fx, status({ stripe: { verbunden: false, status: null } }))
await fx.klick(knopf(fx, panel, 'stripe-einrichten'))
assert.equal(fx.window.location.href, 'http://localhost/dashboard.html', String(url))
assert.equal(ctx.meldungen.at(-1).art, 'fehler')
assert.match(ctx.meldungen.at(-1).text, /keine gültige Adresse/)
assert.equal(ctx.geladen, 1, 'das Konto ist angelegt: Stand neu holen')
} finally {
fx.restore()
}
}
})
test('„Angaben ergänzen“ holt einen neuen Link (POST /stripe/link) und geht dorthin', mit((a) => {
if (a.url === '/api/shop/stripe/link') return { body: { url: URL_STRIPE } }
return { body: {} }
}, async (fx) => {
const { panel } = await zeige(fx, status({ stripe: { verbunden: true, status: { ...STRIPE_OK, currently_due: ['x'] } } }))
await fx.klick(knopf(fx, panel, 'stripe-link'))
assert.deepEqual(posts(fx).map((a) => [a.url, a.body]), [['/api/shop/stripe/link', { projectId: 'p1' }]])
assert.equal(fx.window.location.href, URL_STRIPE)
}))
test('„Status aktualisieren“ schickt { projectId } und zeigt den neuen Stand samt Meldung', mit((a) => {
if (a.url === '/api/shop/stripe/aktualisieren') return { body: { stripe: { verbunden: true, status: { ...STRIPE_OK, charges_enabled: false, currently_due: ['a', 'b'] } } } }
return { body: {} }
}, async (fx) => {
const neuerStand = status({ stripe: { verbunden: true, status: { ...STRIPE_OK, charges_enabled: false, currently_due: ['a', 'b'] } } })
const { panel, ctx } = await zeige(fx, status(), { neu: neuerStand })
await fx.klick(knopf(fx, panel, 'stripe-aktualisieren'))
assert.deepEqual(posts(fx).map((a) => [a.url, a.body]), [['/api/shop/stripe/aktualisieren', { projectId: 'p1' }]])
assert.equal(fx.finde(panel, '[data-stripe="zahlungen"]').textContent, 'Zahlungen: noch nicht freigeschaltet')
assert.equal(fx.finde(panel, '[data-stripe="fehlend"]').textContent, '2 Angaben fehlen noch')
assert.equal(ctx.geladen, 1)
assert.deepEqual(ctx.meldungen, [{ text: 'Der Stand deines Stripe-Kontos ist aktuell.', art: 'ok' }])
}))
test('Rückkehr von Stripe (stripeRueckkehr): der Stand wird einmal automatisch gelesen', mit((a) => {
if (a.url === '/api/shop/stripe/aktualisieren') return { body: { stripe: { verbunden: true, status: STRIPE_OK } } }
return { body: {} }
}, async (fx) => {
const ctx = baueCtx(status({ stripe: { verbunden: true, status: { ...STRIPE_OK, charges_enabled: false } } }), { stripeRueckkehr: 'zurueck', neu: status() })
const panel = h('div')
fx.document.body.append(panel)
await render(panel, ctx)
assert.deepEqual(posts(fx).map((a) => [a.url, a.body]), [['/api/shop/stripe/aktualisieren', { projectId: 'p1' }]])
assert.equal(fx.finde(panel, '[data-stripe="zahlungen"]').textContent, 'Zahlungen: aktiv')
assert.match(ctx.meldungen[0].text, /Willkommen zurück von Stripe/)
assert.equal(ctx.meldungen[0].art, 'ok')
await render(panel, ctx) // Reiterwechsel mit demselben ctx: nicht noch einmal
assert.equal(posts(fx).length, 1)
}))
test('Rückkehr „neu“ (Link abgelaufen): Stand lesen und auf „Angaben ergänzen“ hinweisen; ohne Konto kein Aufruf', mit((a) => {
if (a.url === '/api/shop/stripe/aktualisieren') return { body: { stripe: { verbunden: true, status: STRIPE_OK } } }
return { body: {} }
}, async (fx) => {
const { ctx } = await zeige(fx, status(), { stripeRueckkehr: 'neu' })
assert.equal(posts(fx).length, 1)
assert.match(ctx.meldungen[0].text, /abgelaufen/)
const ohne = await zeige(fx, status({ stripe: { verbunden: false, status: null } }), { stripeRueckkehr: 'zurueck' })
assert.equal(posts(fx).length, 1, 'kein Konto, nichts zu lesen')
assert.equal(ohne.ctx.meldungen.length, 0)
}))
test('Fehler beim automatischen Lesen nach der Rückkehr wird gemeldet', mit(() => ({ status: 502, body: { error: 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.' } }), async (fx) => {
const { ctx, panel } = await zeige(fx, status(), { stripeRueckkehr: 'zurueck' })
assert.deepEqual(ctx.meldungen, [{ text: 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.', art: 'fehler' }])
assert.ok(knopf(fx, panel, 'stripe-aktualisieren'), 'die Karte bleibt bedienbar')
}))
/* ---------------------------------------------------------------- Kopieren --- */
function setzeNavigator(wert) {
const alt = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
Object.defineProperty(globalThis, 'navigator', { value: wert, configurable: true, writable: true, enumerable: true })
return () => (alt ? Object.defineProperty(globalThis, 'navigator', alt) : delete globalThis.navigator)
}
test('„Code kopieren“ schreibt den Code in die Zwischenablage und bestätigt', mit(undefined, async (fx) => {
const kopiert = []
const zurueck = setzeNavigator({ clipboard: { writeText: async (t) => { kopiert.push(t) } } })
try {
const { panel } = await zeige(fx, status())
await fx.klick(knopf(fx, panel, 'kopieren'))
assert.deepEqual(kopiert, [SNIPPET])
assert.equal(fx.finde(panel, '[data-kopiert]').textContent, 'Der Code ist kopiert.')
} finally {
zurueck()
}
}))
test('„Code kopieren“ ohne Zwischenablage: Ausweich über execCommand, sonst Hinweis zum Kopieren von Hand', mit(undefined, async (fx) => {
const zurueck = setzeNavigator({ clipboard: { writeText: async () => { throw new Error('verweigert') } } })
try {
const { panel } = await zeige(fx, status())
fx.document.execCommand = (befehl) => befehl === 'copy'
await fx.klick(knopf(fx, panel, 'kopieren'))
assert.equal(fx.finde(panel, '[data-kopiert]').textContent, 'Der Code ist kopiert.')
assert.equal(fx.alle(fx.document.body, 'textarea').length, 0, 'das Hilfsfeld wird wieder entfernt')
fx.document.execCommand = () => false
await fx.klick(knopf(fx, panel, 'kopieren'))
assert.match(fx.finde(panel, '[data-kopiert]').textContent, /Das Kopieren hat nicht geklappt/)
delete fx.document.execCommand
await fx.klick(knopf(fx, panel, 'kopieren'))
assert.match(fx.finde(panel, '[data-kopiert]').textContent, /Das Kopieren hat nicht geklappt/)
} finally {
zurueck()
}
}))
/* ---------------------------------------------------------------- Nur lesen --- */
test('nurLesen (Shop abgeschaltet): keine Knöpfe, nur Links', mit(undefined, async (fx) => {
const { panel } = await zeige(fx, status({ geoeffnet: false, schalter: true }), { nurLesen: true })
assert.equal(fx.alle(panel, 'button').length, 0)
assert.ok(fx.finde(panel, '[data-link="dashboard"]'))
assert.ok(fx.finde(panel, '[data-link="vorschau"]'))
assert.ok(fx.finde(panel, '[data-zustand]'))
const ohneKonto = await zeige(fx, status({ stripe: { verbunden: false, status: null } }), { nurLesen: true })
assert.equal(fx.alle(ohneKonto.panel, 'button').length, 0)
assert.match(fx.text(karte(fx, ohneKonto.panel, 'Stripe-Konto')), /eigenes Stripe-Konto/)
}))
test('nurLesen: die Rückkehr von Stripe löst keinen Aufruf aus', mit(undefined, async (fx) => {
await zeige(fx, status(), { nurLesen: true, stripeRueckkehr: 'zurueck' })
assert.equal(fx.fetchLog.length, 0)
}))
test('Antwort nach dem Wechsel des Reiters zeichnet nicht in das fremde Panel', mit((a) => {
if (a.url === '/api/shop/oeffnen') return { body: { schalter: true, geoeffnet: true, bereitschaft: bereitschaft(true) } }
return { body: {} }
}, async (fx) => {
const { panel, ctx } = await zeige(fx, status())
const klickt = fx.klick(knopf(fx, panel, 'oeffnen'))
panel.replaceChildren(h('p', null, 'anderer Reiter'))
await klickt
assert.equal(fx.text(panel), 'anderer Reiter')
assert.equal(ctx.meldungen.length, 0)
}))
/* ---------------------------------------------------------------- Quelle --- */
test('Quelltext: kein innerHTML, outerHTML, insertAdjacentHTML, document.write, eval', () => {
const quelle = readFileSync(new URL('../public/shop-tab-uebersicht.js', import.meta.url), 'utf8')
for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function']) assert.ok(!quelle.includes(verboten), verboten)
})
+254
View File
@@ -0,0 +1,254 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Webshop B2: Team-Uebersicht und Entscheidung ueber die Anfrage (routes/team/shop.js, services/shop/anfrage.js entscheideAnfrage), K24
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
featureConsents: schemaKeys('featureConsents'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, rawGet, post } = await import('./support/testApp.js')
const { seedFixtures, customers } = await import('./support/fixtures.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const anfrage = await import('../server/services/shop/anfrage.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { featureHaken } = await import('../server/services/featureRegistry.js')
const { ensureFeatureDoc } = await import('../server/services/siteFeatures.js')
const { resetShopRoutesForTests } = await import('../server/routes/shop.js')
const app = await startTestApp({ '/api/team': teamRoutes })
const mails = mailSink()
mails.versandFehler = false
const sinkMail = mails.sendMail.bind(mails)
mails.sendMail = async (mail) => {
if (mails.versandFehler && /Webshop-Anfrage/.test(mail.subject)) throw new Error('SMTP nicht erreichbar')
return sinkMail(mail)
}
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Töpferei' })
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' })
aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Altprojekt', archived: true })
aw.seed('websiteProjects', { $id: 'pt5', customerId: 'c1', projectName: 'Altshop', archived: true })
const staff = { headers: { authorization: 'Bearer jwt-staff' } }
const GUELTIG = {
warenart: 'kunst',
beschreibung: 'Handgemachte Keramik und Tassen aus eigener Werkstatt.',
artikelzahl: 'bis20',
versand: true,
abholung: false,
unternehmer: true,
keineAusgeschlossenen: true,
nachricht: '',
}
const person = { name: 'Kunde Eins', email: customers.c1.email }
const stelle = (projectId = 'pt1') => anfrage.stelleAnfrage({ customerId: 'c1', projectId, eingabe: GUELTIG, person })
const entscheide = (body, opts = staff, customerId = 'c1') => post(`/api/team/shop/${customerId}/anfrage`, { projectId: 'pt1', ...body }, opts)
const k24 = () => mails.sent.filter((m) => /Webshop-Anfrage/.test(m.subject))
const shopDoc = (projectId = 'pt1') => [...aw.docs('shopConfig').values()].find((d) => d.projectId === projectId) ?? null
const uebersicht = async (customerId = 'c1') => (await rawGet(`/api/team/shop/${customerId}`, staff)).body.projects
let origin
beforeEach(() => {
origin ??= { mode: config.shop.mode, pilotEmails: config.shop.pilotEmails }
config.shop.mode = 'pilot'
config.shop.pilotEmails = [customers.c1.email]
mails.versandFehler = false
resetShopRoutesForTests()
})
async function leereAlles() {
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders']) {
for (const id of aw.docs(col).keys()) await deleteDocument(config.collections[col], id)
}
mails.sent.length = 0
}
test('ohne Anmeldung 401, mit Mitarbeiter-JWT erreichbar', async () => {
assert.equal((await rawGet('/api/team/shop/c1')).status, 401)
assert.equal((await post('/api/team/shop/c1/anfrage', { projectId: 'pt1', entscheidung: 'annehmen' })).status, 401)
assert.equal((await rawGet('/api/team/shop/c1', staff)).status, 200)
})
test('GET: Projekte des Kunden ohne Shop, archivierte nur mit Shop-Dokument', async () => {
await leereAlles()
const ohne = await uebersicht()
assert.deepEqual(ohne.map((p) => p.projectId).sort(), ['pt1', 'pt2'])
assert.deepEqual(ohne[0], {
projectId: ohne[0].projectId,
projectName: ohne[0].projectName,
state: 'off',
anfrage: null,
stripe: { accountId: false, status: null },
geoeffnet: false,
zahlen: { artikel: 0, offeneBestellungen: 0 },
})
assert.deepEqual(await uebersicht('c3'), [])
await ensureFeatureDoc('c1', 'pt5', 'shop')
assert.deepEqual((await uebersicht()).map((p) => p.projectId).sort(), ['pt1', 'pt2', 'pt5'])
})
test('GET: Anfrage, Stripe-Stand, geoeffnet und Zahlen je Projekt', async () => {
await leereAlles()
await stelle('pt1')
const doc = shopDoc('pt1')
await updateDocument(config.collections.shopConfig, doc.$id, {
stripeAccountId: 'acct_1',
stripeStatus: JSON.stringify({ chargesEnabled: true, detailsSubmitted: true }),
geoeffnet: true,
})
for (const [id, shopId, status] of [['o1', doc.$id, 'bezahlt'], ['o2', doc.$id, 'bezahlt'], ['o3', doc.$id, 'offen'], ['o4', doc.$id, 'versendet'], ['o5', 'anderer', 'bezahlt']]) {
aw.seed('shopOrders', { $id: id, shopId, customerId: 'c1', status })
}
aw.seed('shopProducts', { $id: 'a1', shopId: doc.$id, customerId: 'c1', name: 'Tasse' })
aw.seed('shopProducts', { $id: 'a2', shopId: doc.$id, customerId: 'c1', name: 'Teller' })
const p1 = (await uebersicht()).find((p) => p.projectId === 'pt1')
assert.equal(p1.anfrage.status, 'offen')
assert.equal(p1.anfrage.anfrage.warenart, 'kunst')
assert.deepEqual(p1.stripe, { accountId: true, status: { chargesEnabled: true, detailsSubmitted: true } })
assert.equal(p1.geoeffnet, true)
assert.deepEqual(p1.zahlen, { artikel: 2, offeneBestellungen: 2 })
const p2 = (await uebersicht()).find((p) => p.projectId === 'pt2')
assert.equal(p2.anfrage, null)
await updateDocument(config.collections.shopConfig, doc.$id, { stripeStatus: '{kaputt' })
assert.equal((await uebersicht()).find((p) => p.projectId === 'pt1').stripe.status, null, 'unlesbares JSON => null')
})
test('annehmen: speichert Entscheidung und Person, K24 an den Kunden, Bestell-Haken liefert []', async () => {
await leereAlles()
await stelle()
assert.deepEqual(await featureHaken('shop').bestellpruefung({ customerId: 'c1', projectId: 'pt1' }), [anfrage.ANFRAGE_OFFEN_TEXT])
mails.sent.length = 0
const res = await entscheide({ entscheidung: 'annehmen', begruendung: 'wird ignoriert' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.mailFehler, undefined)
assert.equal(res.body.anfrage.status, 'angenommen')
assert.ok(res.body.anfrage.entschiedenAt)
assert.equal(res.body.anfrage.begruendung, '')
const doc = shopDoc()
assert.equal(doc.anfrageStatus, 'angenommen')
assert.equal(doc.anfrageEntschiedenVon, 'Andrej <andrej@webklar.com>')
assert.equal(doc.anfrageBegruendung, '')
assert.equal(doc.version, 2)
assert.deepEqual(await featureHaken('shop').bestellpruefung({ customerId: 'c1', projectId: 'pt1' }), [])
const mail = k24()
assert.equal(mail.length, 1)
assert.equal(mail[0].to[0].address, customers.c1.email)
assert.equal(mail[0].subject, 'Deine Webshop-Anfrage für Töpferei')
assert.match(mail[0].text, /Hallo Kunde Eins/)
assert.match(mail[0].text, /angenommen/)
assert.match(mail[0].text, /299 €/)
assert.match(mail[0].text, /§ 19 UStG/)
assert.doesNotMatch(mail[0].text, /leider/)
assert.match(mail[0].html, /dashboard\.html#webshop\?projekt=pt1"/)
assert.ok(mail[0].html.includes(config.publicBaseUrl))
})
test('ablehnen: Begründung Pflicht (1-1000), gespeichert und in K24', async () => {
await leereAlles()
await stelle()
mails.sent.length = 0
for (const begruendung of [undefined, '', ' ', 5, 'x'.repeat(1001)]) {
const res = await entscheide({ entscheidung: 'ablehnen', begruendung })
assert.equal(res.status, 400, String(begruendung).slice(0, 10))
}
assert.equal(shopDoc().anfrageStatus, 'offen')
assert.equal(k24().length, 0)
const res = await entscheide({ entscheidung: 'ablehnen', begruendung: ` ${'y'.repeat(1000)} ` })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.anfrage.status, 'abgelehnt')
assert.equal(res.body.anfrage.begruendung, 'y'.repeat(1000))
assert.equal(shopDoc().anfrageStatus, 'abgelehnt')
assert.deepEqual(await featureHaken('shop').bestellpruefung({ customerId: 'c1', projectId: 'pt1' }), [anfrage.ANFRAGE_OFFEN_TEXT])
assert.match(k24()[0].text, /leider/)
assert.match(k24()[0].text, /yyyy/)
assert.doesNotMatch(k24()[0].text, /299 €/)
})
test('falscher Zustand 409, fremdes Projekt 403, ungültige Eingabe 400', async () => {
await leereAlles()
// kein Dokument
let res = await entscheide({ entscheidung: 'annehmen' })
assert.equal(res.status, 409)
assert.equal(res.body.code, 'nicht_offen')
await stelle()
assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 200)
mails.sent.length = 0
// schon entschieden: weder annehmen noch ablehnen
assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 409)
assert.equal((await entscheide({ entscheidung: 'ablehnen', begruendung: 'Grund' })).status, 409)
assert.equal(shopDoc().anfrageStatus, 'angenommen')
// zurückgezogen
await stelle('pt2')
await anfrage.zieheZurueck({ customerId: 'c1', projectId: 'pt2', person })
assert.equal((await entscheide({ projectId: 'pt2', entscheidung: 'annehmen' })).status, 409)
assert.equal(k24().length, 0, 'bei 409 keine Mail')
assert.equal((await entscheide({ projectId: 'pt3', entscheidung: 'annehmen' })).status, 403, 'Projekt eines anderen Kunden')
assert.equal((await entscheide({ projectId: 'gibtsnicht', entscheidung: 'annehmen' })).status, 403)
assert.equal((await entscheide({ projectId: undefined, entscheidung: 'annehmen' })).status, 400)
assert.equal((await entscheide({ entscheidung: 'vielleicht' })).status, 400)
assert.equal((await entscheide({ entscheidung: undefined })).status, 400)
assert.equal((await entscheide({ entscheidung: 'annehmen' }, staff, 'c2')).status, 403, 'Projekt gehört nicht zum Kunden der URL')
})
test('Mailfehler: Entscheidung bleibt, Antwort trägt mailFehler', async () => {
await leereAlles()
await stelle()
mails.versandFehler = true
const res = await entscheide({ entscheidung: 'annehmen' })
assert.equal(res.status, 200)
assert.equal(res.body.mailFehler, true)
assert.equal(res.body.anfrage.status, 'angenommen')
assert.equal(shopDoc().anfrageStatus, 'angenommen')
assert.equal(k24().length, 0)
})
test('zwei gleichzeitige Entscheidungen: genau eine gewinnt, genau eine Mail', async () => {
await leereAlles()
await stelle()
mails.sent.length = 0
const [a, b] = await Promise.all([entscheide({ entscheidung: 'annehmen' }), entscheide({ entscheidung: 'ablehnen', begruendung: 'Nein' })])
assert.deepEqual([a.status, b.status].sort(), [200, 409])
assert.equal(k24().length, 1)
assert.equal(shopDoc().anfrageStatus, a.status === 200 ? 'angenommen' : 'abgelehnt')
})
test('nach Ablehnung kann der Kunde neu anfragen, die alte Entscheidung ist gelöscht', async () => {
await leereAlles()
await stelle()
await entscheide({ entscheidung: 'ablehnen', begruendung: 'Nein' })
await stelle()
const doc = shopDoc()
assert.equal(doc.anfrageStatus, 'offen')
assert.equal(doc.anfrageBegruendung, '')
assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 200)
})
+588
View File
@@ -0,0 +1,588 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { centZuEuro, datumText, euroEingabe, euroText, euroZuCent, h } from '../public/shop-dom.js'
import { REITER, bestellungVon, initShop, vergissAuswahl } from '../public/shop.js'
// Task G2: die Hülle #webshop (public/shop.js, shop-anfrage.js, shop-dom.js) mit Fake-DOM und Fake-fetch: jeder Zustand zeigt das Richtige,
// die Anfrage schickt den richtigen Körper, die Bestellseite geht an /api/funktionen/shop/checkout, die Reiter laden ihre Module, die Rückkehr
// wird gelesen und aus der Adresse genommen. Die Server-Antworten sind von Hand gebaut (Form: server/routes/shop.js, funktionen.js).
const URL_ZAHLUNG = 'https://checkout.stripe.test/c/pay/cs_test_shop'
const CONSENT = {
version: 'v-shop',
knopf: 'Zahlungspflichtig bestellen · 299 €',
items: [{ id: 'unterlagen', text: 'Ich habe die Unterlagen gelesen.', haken: true }, { id: 'info', text: 'Hinweis ohne Haken.', haken: false }],
}
const FEATURE = {
key: 'shop',
name: 'Webshop',
priceLabel: '299 €',
setupEstimate: 'meist innerhalb von 1–2 Wochen',
consent: CONSENT,
leistungsbeschreibung: { version: '2026-10-05', url: '/rechtliches/webshop-leistungsbeschreibung-2026-10-05.pdf' },
avv: { version: '2026-10-05', url: '/rechtliches/webshop-avv-2026-10-05.pdf' },
widerruf: { version: '2026-10-05', url: '' },
}
const UST = 'einmalig, ohne Umsatzsteuer gemäß § 19 UStG'
const ANFRAGE = { warenart: 'kleidung', beschreibung: 'Handgemachte Mützen und Schals.', artikelzahl: 'bis20', versand: true, abholung: false, unternehmer: true, keineAusgeschlossenen: true, nachricht: '' }
const projekt = (id, name, state = 'off', extra = {}) => ({ projectId: id, projectName: name, features: { shop: { state, ...extra } } })
const funktionen = (projects, { verfuegbar = true } = {}) => ({
projects,
features: { shop: FEATURE },
ustHinweis: UST,
legal: { shop: { version: '2026-10-05', links: {}, belehrung: 'Widerrufsbelehrung samt Muster-Widerrufsformular.' } },
shop: { verfuegbar },
})
const status = (state, extra = {}) => ({
verfuegbar: true, state, ticketWoid: null, anfrage: null, bereitschaft: null, geoeffnet: false, schalter: false, siteKey: null,
snippet: null, vorschauUrl: null, stripe: { verbunden: false, status: null }, zahlen: null, ...extra,
})
const anfrageAnsicht = (stand, extra = {}) => ({ status: stand, anfrage: ANFRAGE, at: '2026-10-05T10:30:00.000Z', entschiedenAt: null, begruendung: '', ...extra })
/**
* Fake-Server: GET /api/funktionen und GET /api/shop/status?projectId= antworten aus dem veränderlichen Zustand `welt`; alle anderen
* Aufrufe nimmt `sonst(aufruf, welt)` entgegen (Standard 200 {}). Jeder Aufruf steht in fx.fetchLog.
*/
function starteMitWelt(welt, sonst = () => ({})) {
const route = (aufruf) => {
if (aufruf.method === 'GET' && aufruf.url === '/api/funktionen') return { body: welt.funktionen }
if (aufruf.method === 'GET' && aufruf.url.startsWith('/api/shop/status?projectId=')) {
const id = decodeURIComponent(aufruf.url.split('=')[1])
return { body: welt.status[id] ?? status('off') }
}
return sonst(aufruf, welt) || { body: {} }
}
return installFakeDom({ antworten: Array.from({ length: 60 }, () => route) })
}
const mitDom = (welt, sonst, fn) => async () => {
const fx = starteMitWelt(welt, sonst)
try {
await fn(fx)
} finally {
fx.restore()
}
}
async function oeffne(fx, { hash = '', wunsch = '', projectId, behalten = false } = {}) {
if (!behalten) vergissAuswahl()
fx.window.location.hash = hash
const root = h('div', { id: 'webshop-body' })
if (wunsch) root.dataset.wunschProjekt = wunsch
fx.document.body.append(root)
await initShop(root, projectId ? { projectId } : {})
await fx.ruhe()
return root
}
const posts = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET')
const welt1 = (zustand, stand, extra) => ({ funktionen: funktionen([projekt('p1', 'Mein Laden', zustand, extra)]), status: { p1: stand } })
/* ------------------------------------------------------------------ Hilfen --- */
test('euroZuCent und centZuEuro: deutsche Schreibweise, nur Anzeige', () => {
assert.equal(euroZuCent('12,50'), 1250)
assert.equal(euroZuCent('12,5'), 1250)
assert.equal(euroZuCent('12.50 €'), 1250)
assert.equal(euroZuCent('1.234,50'), 123450)
assert.equal(euroZuCent('7'), 700)
for (const schlecht of ['', 'abc', '12,345', '1,2,3', '-5', null, undefined]) assert.equal(euroZuCent(schlecht), null, String(schlecht))
assert.equal(centZuEuro(1250), '12,50')
assert.equal(centZuEuro(5), '0,05')
assert.equal(centZuEuro(-1), '')
assert.equal(euroText(123450), '1.234,50 €')
assert.equal(euroText(null), '')
})
test('euroEingabe: Eingabe wird beim Verlassen in die Schreibweise 12,50 gebracht, inCent liefert Cent oder null', async () => {
const fx = installFakeDom()
try {
const feld = euroEingabe({ cent: 990 })
assert.equal(feld.value, '9,90')
assert.equal(feld.type, 'text', 'Text statt number: Komma bleibt erlaubt')
await fx.setze(feld, '12,5')
assert.equal(feld.value, '12,50')
assert.equal(feld.inCent(), 1250)
await fx.setze(feld, 'zwölf')
assert.equal(feld.value, 'zwölf', 'Unlesbares bleibt stehen, der Server meldet den Fehler')
assert.equal(feld.inCent(), null)
assert.equal(feld.istLesbar(), false)
await fx.setze(feld, '')
assert.equal(feld.istLeer(), true)
assert.equal(feld.istLesbar(), true)
assert.equal(feld.inCent(), null)
} finally {
fx.restore()
}
})
test('datumText: Berliner Zeit, Datum allein, ungültig leer', () => {
assert.match(datumText('2026-10-05T12:30:00.000Z'), /^05\.10\.2026,? 14:30$/)
assert.equal(datumText('2026-10-05T12:30:00.000Z', { zeit: false }), '05.10.2026')
assert.equal(datumText(''), '')
assert.equal(datumText('kein Datum'), '')
assert.equal(datumText(null), '')
})
/* ---------------------------------------------------------------- Zustände --- */
test('nicht verfügbar: Infokarte, keine Abfrage des Stands', mitDom({ funktionen: funktionen([projekt('p1', 'A')], { verfuegbar: false }), status: {} }, undefined, async (fx) => {
const root = await oeffne(fx)
assert.match(fx.text(root), /Der Webshop ist noch nicht verfügbar\./)
assert.deepEqual(fx.fetchLog.map((a) => a.url), ['/api/funktionen'])
}))
test('keine Projekte: leere Karte', mitDom({ funktionen: funktionen([]), status: {} }, undefined, async (fx) => {
const root = await oeffne(fx)
assert.match(fx.text(root), /Noch keine Website-Projekte zugewiesen\./)
assert.equal(fx.finde(root, 'select'), null)
}))
test('Fehler beim Laden: Fehlerkarte mit „Noch einmal versuchen“', async () => {
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Interner Serverfehler' } }, { body: funktionen([]) }] })
try {
const root = h('div')
fx.document.body.append(root)
await initShop(root)
assert.match(fx.text(root), /Interner Serverfehler/)
await fx.klick(fx.finde(root, 'button'))
assert.match(fx.text(root), /Noch keine Website-Projekte|Der Webshop ist noch nicht verfügbar/)
} finally {
fx.restore()
}
})
test('keine Anfrage: Erklärung, Formular mit den Pflichthaken, Knopf „Anfrage senden“', mitDom(welt1('off', status('off')), undefined, async (fx) => {
const root = await oeffne(fx)
const text = fx.text(root)
assert.match(text, /299 €/)
assert.match(text, /eigenes Stripe-Konto/)
assert.match(text, /Karte, Apple Pay, Google Pay, Klarna oder SEPA/)
assert.match(text, /PayPal ist über Stripe leider nicht möglich/)
assert.match(text, /Ich verkaufe als Unternehmer \(nicht privat\)\./)
assert.match(text, /Ich verkaufe keine ausgeschlossenen Waren \(Alkohol, Tabak\/E-Zigaretten, Arzneimittel, Waffen\/Feuerwerk, Erotik, lebende Tiere, digitale Inhalte, Gutscheine\)\./)
assert.equal(fx.text(fx.finde(root, '[data-aktion="anfrage-senden"]')), 'Anfrage senden')
for (const name of ['warenart', 'beschreibung', 'artikelzahl', 'versand', 'abholung', 'unternehmer', 'keineAusgeschlossenen', 'nachricht']) {
assert.ok(fx.finde(root, `[data-feld="${name}"]`), `Feld ${name}`)
}
assert.equal(fx.finde(root, '[data-feld="unternehmer"]').checked, false, 'Pflichthaken sind nie vorausgewählt')
assert.deepEqual(fx.fetchLog.map((a) => a.url), ['/api/funktionen', '/api/shop/status?projectId=p1'])
}))
test('Anfrage senden: Körper laut Schnittstelle, danach zeigt die Ansicht „wird geprüft“', mitDom(
welt1('off', status('off')),
(aufruf, welt) => {
if (aufruf.method === 'POST' && aufruf.url === '/api/shop/anfrage') {
welt.status.p1 = status('off', { anfrage: anfrageAnsicht('offen') })
return { body: { anfrage: anfrageAnsicht('offen') } }
}
return null
},
async (fx) => {
const root = await oeffne(fx)
await fx.setze(fx.finde(root, '[data-feld="warenart"]'), 'kleidung')
await fx.setze(fx.finde(root, '[data-feld="beschreibung"]'), ' Handgemachte Mützen und Schals. ')
await fx.setze(fx.finde(root, '[data-feld="artikelzahl"]'), 'bis20')
await fx.klick(fx.finde(root, '[data-feld="versand"]'))
await fx.klick(fx.finde(root, '[data-feld="unternehmer"]'))
await fx.klick(fx.finde(root, '[data-feld="keineAusgeschlossenen"]'))
await fx.klick(fx.finde(root, '[data-aktion="anfrage-senden"]'))
await fx.ruhe()
assert.deepEqual(posts(fx), [{
method: 'POST',
url: '/api/shop/anfrage',
body: { projectId: 'p1', anfrage: { warenart: 'kleidung', beschreibung: 'Handgemachte Mützen und Schals.', artikelzahl: 'bis20', versand: true, abholung: false, unternehmer: true, keineAusgeschlossenen: true, nachricht: '' } },
}])
assert.match(fx.text(root), /Deine Anfrage wird geprüft/)
assert.ok(fx.finde(root, '[data-aktion="zurueckziehen"]'))
},
))
test('Anfrage mit Feldfehlern vom Server: Fehler stehen am Feld, nichts wird neu gezeichnet, der Knopf ist wieder frei', mitDom(
welt1('off', status('off')),
(aufruf) => (aufruf.method === 'POST'
? { status: 400, body: { error: 'Bitte wähle, was du verkaufen möchtest.', fehler: { warenart: 'Bitte wähle, was du verkaufen möchtest.', unternehmer: 'Bitte bestätige, dass du als Unternehmer verkaufst.', _: 'Sonst etwas.' } } }
: null),
async (fx) => {
const root = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-aktion="anfrage-senden"]'))
await fx.ruhe()
assert.equal(fx.text(fx.finde(root, '[data-fehler="warenart"]')), 'Bitte wähle, was du verkaufen möchtest.')
assert.equal(fx.text(fx.finde(root, '[data-fehler="unternehmer"]')), 'Bitte bestätige, dass du als Unternehmer verkaufst.')
assert.equal(fx.text(fx.finde(root, '[data-fehler="_"]')), 'Sonst etwas.')
assert.equal(fx.finde(root, '[data-feld="warenart"]').getAttribute('aria-invalid'), 'true')
assert.equal(fx.finde(root, '[data-aktion="anfrage-senden"]').disabled, false)
assert.equal(fx.fetchLog.filter((a) => a.url.startsWith('/api/shop/status')).length, 1, 'kein Neuladen nach einem 400')
},
))
test('Anfrage offen: Karte mit Zusammenfassung; „Anfrage zurückziehen“ schickt POST und zeigt wieder das Formular, vorausgefüllt', mitDom(
welt1('off', status('off', { anfrage: anfrageAnsicht('offen') })),
(aufruf, welt) => {
if (aufruf.url === '/api/shop/anfrage/zurueckziehen') {
welt.status.p1 = status('off', { anfrage: anfrageAnsicht('zurueckgezogen') })
return { body: { anfrage: anfrageAnsicht('zurueckgezogen') } }
}
return null
},
async (fx) => {
const root = await oeffne(fx)
const text = fx.text(root)
assert.match(text, /Deine Anfrage wird geprüft/)
assert.match(text, /Kleidung/)
assert.match(text, /Handgemachte Mützen und Schals\./)
assert.match(text, /bis 20/)
assert.match(text, /Versand/)
assert.equal(fx.finde(root, '[data-aktion="anfrage-senden"]'), null)
await fx.klick(fx.finde(root, '[data-aktion="zurueckziehen"]'))
await fx.ruhe()
assert.deepEqual(posts(fx), [{ method: 'POST', url: '/api/shop/anfrage/zurueckziehen', body: { projectId: 'p1' } }])
assert.ok(fx.finde(root, '[data-aktion="anfrage-senden"]'), 'zurückgezogen: wieder das Formular')
assert.equal(fx.finde(root, '[data-feld="beschreibung"]').value, 'Handgemachte Mützen und Schals.')
assert.equal(fx.finde(root, '[data-feld="warenart"]').value, 'kleidung')
assert.equal(fx.finde(root, '[data-feld="versand"]').checked, true)
},
))
test('Zurückziehen scheitert mit 409 (inzwischen entschieden): Meldung, der neue Stand erscheint', mitDom(
welt1('off', status('off', { anfrage: anfrageAnsicht('offen') })),
(aufruf, welt) => {
if (aufruf.url === '/api/shop/anfrage/zurueckziehen') {
welt.status.p1 = status('off', { anfrage: anfrageAnsicht('abgelehnt', { begruendung: 'Passt nicht zu unserem Angebot.' }) })
return { status: 409, body: { error: 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.' } }
}
return null
},
async (fx) => {
const root = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-aktion="zurueckziehen"]'))
await fx.ruhe()
assert.match(fx.text(root), /Passt nicht zu unserem Angebot\./)
},
))
test('Anfrage abgelehnt: Begründung als Text; „Neue Anfrage stellen“ zeigt das Formular mit den alten Angaben', mitDom(
welt1('off', status('off', { anfrage: anfrageAnsicht('abgelehnt', { begruendung: 'Wir verkaufen <b>keine</b> Mützen.' }) })),
undefined,
async (fx) => {
const root = await oeffne(fx)
assert.match(fx.text(root), /Wir verkaufen <b>keine<\/b> Mützen\./, 'Fremdtext bleibt Text')
assert.equal(fx.finde(root, 'blockquote b'), null, 'kein Element aus dem Text')
assert.equal(fx.finde(root, '[data-aktion="anfrage-senden"]'), null)
await fx.klick(fx.finde(root, '[data-aktion="neue-anfrage"]'))
assert.ok(fx.finde(root, '[data-aktion="anfrage-senden"]'))
assert.equal(fx.finde(root, '[data-feld="beschreibung"]').value, 'Handgemachte Mützen und Schals.')
assert.equal(fx.finde(root, '[data-feld="unternehmer"]').checked, false, 'die Haken setzt du neu')
},
))
test('Anfrage angenommen, Funktion aus: Bestellseite mit Preis, Unterlagen, Belehrung und Zusammenfassung; Bestellen geht an /api/funktionen/shop/checkout', mitDom(
welt1('off', status('off', { anfrage: anfrageAnsicht('angenommen') })),
(aufruf) => (aufruf.url === '/api/funktionen/shop/checkout' ? { body: { url: URL_ZAHLUNG } } : null),
async (fx) => {
const root = await oeffne(fx)
const text = fx.text(root)
assert.match(text, /Prüfen und bestellen/)
assert.match(text, /299 €/)
assert.match(text, new RegExp(UST))
assert.match(text, /Widerrufsbelehrung samt Muster-Widerrufsformular\./)
assert.match(text, /Deine Anfrage/)
assert.match(text, /Mützen und Schals/)
assert.equal(fx.finde(root, '[data-aktion="aendern"]'), null, 'ohne Schritte kein „Ändern“')
assert.doesNotMatch(text, /Mit „Ändern“/)
const links = fx.alle(root, 'a').map((a) => a.getAttribute('href'))
assert.deepEqual(links, ['/rechtliches/webshop-leistungsbeschreibung-2026-10-05.pdf', '/rechtliches/webshop-avv-2026-10-05.pdf'], 'nur Unterlagen mit Adresse')
const knopf = fx.finde(root, '[data-aktion="bestellen"]')
assert.equal(fx.text(knopf), 'Zahlungspflichtig bestellen · 299 €')
assert.equal(knopf.disabled, true, 'ohne Haken gesperrt')
await fx.klick(fx.finde(root, '[data-haken="unterlagen"]'))
await fx.klick(knopf)
await fx.ruhe()
assert.deepEqual(posts(fx), [{ method: 'POST', url: '/api/funktionen/shop/checkout', body: { projectId: 'p1', zustimmung: { version: 'v-shop', haken: ['unterlagen'] } } }])
assert.equal(fx.window.location.href, URL_ZAHLUNG)
},
))
test('bestellungVon: ohne Register-Eintrag null, sonst Preis, Hinweis, Einrichtung, Zustimmungen, Unterlagen mit Adresse, Belehrung', () => {
assert.equal(bestellungVon({}), null)
assert.equal(bestellungVon({ features: { shop: {} } }), null)
const b = bestellungVon(funktionen([]))
assert.equal(b.preis, '299 €')
assert.equal(b.preisHinweis, UST)
assert.equal(b.einrichtung, 'meist innerhalb von 1–2 Wochen')
assert.equal(b.consent, CONSENT)
assert.deepEqual(b.dokumente.map((d) => d.id), ['leistungsbeschreibung', 'avv'])
assert.equal(b.belehrung, 'Widerrufsbelehrung samt Muster-Widerrufsformular.')
assert.equal(bestellungVon({ ...funktionen([]), legal: { shop: null } }).belehrung, null)
})
test('Zustand ordered: Statuskarte „Zahlung offen“; „Zahlung fortsetzen“ schickt fortsetzen: true; nach der Zahlung (bestellt=1) steht „Danke!“ da und die Adresse ist bereinigt', mitDom(
welt1('ordered', status('ordered', { anfrage: anfrageAnsicht('angenommen') }), { orderedAt: '2026-10-05T09:00:00.000Z' }),
(aufruf) => (aufruf.url === '/api/funktionen/shop/checkout' ? { body: { url: URL_ZAHLUNG } } : null),
async (fx) => {
const root = await oeffne(fx, { hash: '#webshop?bestellt=1&projekt=p1' })
assert.equal(fx.window.location.hash, '#webshop', 'Rückkehr einmal gelesen und aus der Adresse genommen')
const text = fx.text(root)
assert.match(text, /Deine Bestellung wartet auf die Zahlung/)
assert.match(text, /Danke!/)
assert.match(text, /05\.10\.2026/)
await fx.klick(fx.finde(root, '[data-aktion="fortsetzen"]'))
await fx.ruhe()
assert.deepEqual(posts(fx), [{ method: 'POST', url: '/api/funktionen/shop/checkout', body: { projectId: 'p1', fortsetzen: true } }])
assert.equal(fx.window.location.href, URL_ZAHLUNG)
},
))
test('ordered ohne Rückkehr: kein „Danke!“; scheitert „Zahlung fortsetzen“ mit zustimmung_fehlt, erscheint die Bestellseite mit der Meldung', mitDom(
welt1('ordered', status('ordered', { anfrage: anfrageAnsicht('angenommen') })),
(aufruf) => (aufruf.url === '/api/funktionen/shop/checkout'
? { status: 409, body: { error: 'Bitte bestätige die Unterlagen noch einmal.', code: 'zustimmung_fehlt' } }
: null),
async (fx) => {
const root = await oeffne(fx)
assert.doesNotMatch(fx.text(root), /Danke!/)
await fx.klick(fx.finde(root, '[data-aktion="fortsetzen"]'))
await fx.ruhe()
assert.ok(fx.finde(root, '[data-aktion="bestellen"]'), 'Bestellseite statt Karte')
assert.match(fx.text(root), /Bitte bestätige die Unterlagen noch einmal\./)
},
))
// Wartet, bis ein dynamisch geladenes Reiter-Modul sein Panel gezeichnet hat (Node 20 laedt import() langsamer als Node 24)
async function panelText(fx, root, sel) {
for (let i = 0; i < 100; i += 1) {
const t = fx.text(fx.finde(root, sel))
if (t !== '') return t
await fx.ruhe()
await new Promise((r) => setTimeout(r, 5))
}
return fx.text(fx.finde(root, sel))
}
/* ------------------------------------------------------------------ Reiter --- */
test('Zustand active: fünf Reiter in fester Reihenfolge, Übersicht offen; kein Hinweis-Banner', mitDom(welt1('active', status('active')), undefined, async (fx) => {
const root = await oeffne(fx)
const knoepfe = fx.alle(root, '[role="tab"]')
assert.deepEqual(knoepfe.map((k) => fx.text(k)), ['Übersicht', 'Artikel', 'Bestellungen', 'Einstellungen', 'Rechtliches'])
assert.deepEqual(knoepfe.map((k) => k.dataset.reiter), ['uebersicht', 'artikel', 'bestellungen', 'einstellungen', 'rechtliches'])
assert.deepEqual(knoepfe.map((k) => k.getAttribute('aria-selected')), ['true', 'false', 'false', 'false', 'false'])
assert.equal(fx.finde(root, '[role="tablist"]').getAttribute('aria-label'), 'Bereiche des Webshops')
assert.notEqual(await panelText(fx, root, '[role="tabpanel"]'), '', 'die Übersicht zeichnet ihr Panel')
assert.equal(fx.finde(root, '[data-hinweis]'), null)
assert.deepEqual(REITER.map((r) => r.key), ['uebersicht', 'artikel', 'bestellungen', 'einstellungen', 'rechtliches'])
}))
test('Reiterwechsel per Klick und Pfeiltasten lädt das Modul des Reiters mit ctx { projectId, status, neuLaden, zeigeMeldung, request }', mitDom(welt1('active', status('active', { siteKey: 'sk1' })), undefined, async (fx) => {
const gesehen = []
const alt = REITER.map((r) => r.laden)
REITER.forEach((r, i) => { r.laden = async () => ({ render: (panel, ctx) => { gesehen.push({ key: r.key, ctx }); panel.replaceChildren(h('p', null, `Inhalt ${r.key}`)) } }) })
try {
const root = await oeffne(fx)
assert.deepEqual(gesehen.map((g) => g.key), ['uebersicht'])
const ctx = gesehen[0].ctx
assert.equal(ctx.projectId, 'p1')
assert.equal(ctx.status.siteKey, 'sk1')
assert.equal(ctx.nurLesen, false)
assert.equal(typeof ctx.neuLaden, 'function')
assert.equal(typeof ctx.zeigeMeldung, 'function')
assert.equal(typeof ctx.request, 'function')
await fx.klick(fx.finde(root, '#sh-tab-artikel'))
await fx.ruhe()
assert.deepEqual(gesehen.map((g) => g.key), ['uebersicht', 'artikel'])
assert.match(fx.text(fx.finde(root, '[role="tabpanel"]')), /Inhalt artikel/)
assert.equal(fx.finde(root, '#sh-tab-artikel').getAttribute('aria-selected'), 'true')
assert.equal(fx.finde(root, '#sh-tab-uebersicht').tabIndex, -1)
assert.equal(fx.finde(root, '#sh-panel').getAttribute('aria-labelledby'), 'sh-tab-artikel')
await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'ArrowRight' })
await fx.ruhe()
assert.equal(gesehen.at(-1).key, 'bestellungen')
await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'End' })
await fx.ruhe()
assert.equal(gesehen.at(-1).key, 'rechtliches')
await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'ArrowRight' })
await fx.ruhe()
assert.equal(gesehen.at(-1).key, 'uebersicht', 'am Ende springt es nach vorn')
await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'Home' })
await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'ArrowLeft' })
await fx.ruhe()
assert.equal(gesehen.at(-1).key, 'rechtliches', 'vor dem ersten Reiter springt es ans Ende')
} finally {
REITER.forEach((r, i) => { r.laden = alt[i] })
}
}))
test('Die echten Reiter-Module werden beim Wählen geladen', mitDom(welt1('active', status('active')), undefined, async (fx) => {
const root = await oeffne(fx)
for (const key of ['artikel', 'bestellungen', 'einstellungen', 'rechtliches', 'uebersicht']) {
await fx.klick(fx.finde(root, `#sh-tab-${key}`))
await fx.ruhe()
assert.notEqual(await panelText(fx, root, '#sh-panel'), '', `${key} zeichnet sein Panel (Platzhalter oder echter Inhalt)`)
}
for (const r of REITER) assert.equal(typeof (await r.laden()).render, 'function', `${r.key} exportiert render`)
}))
test('Zustand setup: Hinweis „Wir bauen deinen Shop gerade ein …“ über den Reitern, Reiter nicht schreibgeschützt', mitDom(welt1('setup', status('setup')), undefined, async (fx) => {
const gesehen = []
const alt = REITER[0].laden
REITER[0].laden = async () => ({ render: (panel, ctx) => { gesehen.push(ctx) } })
try {
const root = await oeffne(fx)
assert.equal(fx.text(fx.finde(root, '[data-hinweis="setup"]')), 'Wir bauen deinen Shop gerade ein. Du kannst schon Artikel, Stripe und Rechtstexte vorbereiten.')
assert.equal(fx.alle(root, '[role="tab"]').length, 5)
assert.equal(gesehen[0].nurLesen, false)
} finally {
REITER[0].laden = alt
}
}))
test('Zustand disabled: Banner der Abschaltung, ctx.nurLesen = true', mitDom(welt1('disabled', status('disabled')), undefined, async (fx) => {
const gesehen = []
const alt = REITER[0].laden
REITER[0].laden = async () => ({ render: (panel, ctx) => { gesehen.push(ctx) } })
try {
const root = await oeffne(fx)
assert.equal(fx.text(fx.finde(root, '[data-hinweis="disabled"]')), 'Dein Webshop ist abgeschaltet. Du kannst Bestellungen noch ansehen, bearbeiten, erstatten und exportieren.')
assert.equal(gesehen[0].nurLesen, true)
assert.equal(gesehen[0].abwickeln, true, 'Bestellungen und Widerrufe lassen sich auch bei abgeschaltetem Shop abwickeln (M2)')
assert.equal(fx.alle(root, '[role="tab"]').length, 5)
} finally {
REITER[0].laden = alt
}
}))
test('Stripe-Rückkehr (#webshop?stripe=zurueck): Adresse bereinigt, Übersicht gewählt, ctx.stripeRueckkehr gesetzt; ungültiger Wert ignoriert', mitDom(welt1('active', status('active')), undefined, async (fx) => {
const gesehen = []
const alt = REITER.map((r) => r.laden)
REITER.forEach((r) => { r.laden = async () => ({ render: (panel, ctx) => { gesehen.push({ key: r.key, rueck: ctx.stripeRueckkehr }) } }) })
try {
await oeffne(fx, { hash: '#webshop?stripe=neu&projekt=p1' })
assert.equal(fx.window.location.hash, '#webshop')
assert.deepEqual(gesehen, [{ key: 'uebersicht', rueck: 'neu' }])
gesehen.length = 0
await oeffne(fx, { hash: '#webshop?stripe=%3Cscript%3E' })
assert.deepEqual(gesehen, [{ key: 'uebersicht', rueck: '' }])
} finally {
REITER.forEach((r, i) => { r.laden = alt[i] })
}
}))
test('Danke nach der Zahlung, wenn der Webhook schon durch ist (setup): Meldung „Danke!“ in der Live-Region', mitDom(welt1('setup', status('setup')), undefined, async (fx) => {
const root = await oeffne(fx, { hash: '#webshop?bestellt=1&projekt=p1' })
assert.equal(fx.window.location.hash, '#webshop')
assert.match(fx.text(fx.finde(root, '.sh-meldung')), /Danke! Die Zahlung ist bei uns angekommen\./)
}))
test('ctx.neuLaden: holt den Stand und hält ctx.status aktuell; bei neuem Zustand zeichnet die Hülle die Ansicht neu', mitDom(welt1('setup', status('setup')), undefined, async (fx) => {
let ctx = null
const alt = REITER[0].laden
REITER[0].laden = async () => ({ render: (panel, c) => { ctx = c } })
try {
const root = await oeffne(fx)
assert.ok(fx.finde(root, '[data-hinweis="setup"]'))
// gleicher Zustand: nur ctx.status ist neu, die Ansicht bleibt
const gleich = await ctx.neuLaden()
assert.equal(gleich.state, 'setup')
assert.equal(ctx.status, gleich)
assert.ok(fx.finde(root, '[data-hinweis="setup"]'))
// anderer Zustand: neu gezeichnet, ohne Banner
fx.antworten.length = 0
fx.antworten.push(() => ({ body: status('active', { siteKey: 'neu' }) }))
const aktiv = await ctx.neuLaden()
await fx.ruhe()
assert.equal(aktiv.state, 'active')
assert.equal(fx.finde(root, '[data-hinweis="setup"]'), null)
} finally {
REITER[0].laden = alt
}
}))
test('ctx.zeigeMeldung schreibt in die Live-Region (ok: status, fehler: alert)', mitDom(welt1('active', status('active')), undefined, async (fx) => {
let ctx = null
const alt = REITER[0].laden
REITER[0].laden = async () => ({ render: (panel, c) => { ctx = c } })
try {
const root = await oeffne(fx)
const live = fx.finde(root, '.sh-meldung')
ctx.zeigeMeldung('Gespeichert.', 'ok')
assert.equal(fx.text(live), 'Gespeichert.')
assert.equal(live.getAttribute('role'), 'status')
ctx.zeigeMeldung('Das ging schief.', 'fehler')
assert.equal(live.getAttribute('role'), 'alert')
assert.equal(live.getAttribute('aria-live'), 'assertive')
ctx.zeigeMeldung('')
assert.equal(fx.text(live), '')
} finally {
REITER[0].laden = alt
}
}))
/* ------------------------------------------------------------ Projektwahl --- */
test('Projektwahl: bei mehr als einem Projekt ein Auswahlfeld; das Wunschprojekt aus dem Behälter gilt, Wechsel lädt den Stand des anderen', mitDom({
funktionen: funktionen([projekt('p1', 'Laden Eins', 'active'), projekt('p2', 'Laden Zwei', 'off')]),
status: { p1: status('active'), p2: status('off', { anfrage: anfrageAnsicht('offen') }) },
}, undefined, async (fx) => {
const root = await oeffne(fx, { wunsch: 'p2' })
assert.equal(root.dataset.wunschProjekt, undefined, 'der Wunsch ist verbraucht')
const select = fx.finde(root, '#shop-project')
assert.equal(select.value, 'p2')
assert.match(fx.text(root), /Deine Anfrage wird geprüft/)
await fx.setze(select, 'p1')
await fx.ruhe()
assert.equal(fx.alle(root, '[role="tab"]').length, 5)
assert.ok(fx.fetchLog.some((a) => a.url === '/api/shop/status?projectId=p1'))
}))
test('Ohne Wunsch wird das aktive Projekt gewählt; ein einziges Projekt hat kein Auswahlfeld', mitDom({
funktionen: funktionen([projekt('p1', 'Eins', 'off'), projekt('p2', 'Zwei', 'active')]),
status: { p1: status('off'), p2: status('active') },
}, undefined, async (fx) => {
const root = await oeffne(fx)
assert.equal(fx.finde(root, '#shop-project').value, 'p2')
assert.equal(fx.alle(root, '[role="tab"]').length, 5)
}))
test('Rückkehr mit &projekt= wählt dieses Projekt', mitDom({
funktionen: funktionen([projekt('p1', 'Eins', 'active'), projekt('p2', 'Zwei', 'ordered')]),
status: { p1: status('active'), p2: status('ordered', { anfrage: anfrageAnsicht('angenommen') }) },
}, undefined, async (fx) => {
const root = await oeffne(fx, { hash: '#webshop?bestellt=1&projekt=p2' })
assert.equal(fx.finde(root, '#shop-project').value, 'p2')
assert.match(fx.text(root), /Danke!/)
}))
test('Ein Projekt: kein Auswahlfeld', mitDom(welt1('off', status('off')), undefined, async (fx) => {
const root = await oeffne(fx)
assert.equal(fx.finde(root, 'select#shop-project'), null)
}))
/* ---------------------------------------------------------------- statisch --- */
const SHOP_DATEIEN = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^shop.*\.js$/.test(f))
const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
test('statisch: shop*.js hat kein innerHTML, outerHTML, insertAdjacentHTML, document.write und keine Importe aus termine*.js', () => {
assert.ok(SHOP_DATEIEN.includes('shop.js') && SHOP_DATEIEN.includes('shop-dom.js') && SHOP_DATEIEN.includes('shop-anfrage.js'))
assert.equal(SHOP_DATEIEN.filter((f) => /^shop-tab-/.test(f)).length, 5)
for (const datei of SHOP_DATEIEN) {
const quelle = readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8')
assert.doesNotMatch(ohneKommentare(quelle), /innerHTML|outerHTML|insertAdjacentHTML|document\.write/, datei)
assert.doesNotMatch(ohneKommentare(quelle), /from '\.\/termine[^']*'/, `${datei}: eigene Helfer statt termine-dom.js`)
}
})
test('statisch: Du-Form in den Texten des Portals (kein „Sie“, kein „Ihre“ in shop*.js)', () => {
for (const datei of SHOP_DATEIEN) {
const quelle = ohneKommentare(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8'))
assert.doesNotMatch(quelle, /\bSie\b/, datei)
assert.doesNotMatch(quelle, /\bIh(r|re|ren|rem|rer|res|nen)\b/, datei)
}
})
test('statisch: Die Hülle nutzt für den Stand GET /api/shop/status, für die Anfrage die zwei Routen und für die Bestellung den Schlüssel shop', () => {
const huelle = readFileSync(new URL('../public/shop.js', import.meta.url), 'utf8')
const anfrage = readFileSync(new URL('../public/shop-anfrage.js', import.meta.url), 'utf8')
assert.match(huelle, /\/api\/shop\/status\?projectId=/)
assert.match(huelle, /key: 'shop'/)
assert.match(anfrage, /'\/api\/shop\/anfrage'/)
assert.match(anfrage, /'\/api\/shop\/anfrage\/zurueckziehen'/)
})
+396
View File
@@ -0,0 +1,396 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Webshop D3: Connect-Webhook (signierte Ereignisse an POST /api/shop/stripe-webhook). Mails landen in der Senke.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const K = await import('../server/services/shop/kontext.js')
const B = await import('../server/services/shop/bestellungen.js')
const { startTestApp, postWebhook, send } = await import('./support/testApp.js')
const { default: webhookRoutes } = await import('../server/routes/shopWebhook.js')
const app = await startTestApp({ '/api/shop/stripe-webhook': webhookRoutes })
const { normalisiereProdukt } = await import('../server/services/shop/produkte.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
after(async () => {
await app.close()
setMailTransportForTests(null)
await aw.close()
await st.close()
})
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_shop'
const SITE_KEY = 'abcdefghijklmnop'
const ACCOUNT = 'acct_haendler1'
const T0 = new Date('2026-10-05T12:00:00.000Z')
const nach = (s) => new Date(T0.getTime() + s * 1000)
const PORTAL = 'https://project.webklar.com'
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
kleinunternehmer: true,
lieferzeit: '2–4 Werktage',
shopname: 'Brot & Mehr',
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' },
abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' },
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null },
zahlungsarten: ['card'],
}
const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' }
const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN)
const featureDoc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true }
const shopDoc = (over = {}) => ({
$id: FEATURE,
customerId: KUNDE,
projectId: PROJEKT,
siteKey: SITE_KEY,
anfrageStatus: 'angenommen',
stripeAccountId: ACCOUNT,
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }),
einstellungen: JSON.stringify(EINSTELLUNGEN),
rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }),
geoeffnet: true,
version: 1,
...over,
})
const artikel = (id, eingabe = {}) => {
const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...eingabe }, { einstellungen: { kleinunternehmer: true } })
assert.equal(r.fehler, undefined, JSON.stringify(r.fehler))
return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt) }
}
let sink
let log
let ctx
async function aufbau({ produkte = [artikel('a1', { bestand: 5 })], shop = {} } = {}) {
for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects', 'termineOutbox', 'customers']) {
for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id)
}
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'https://www.brot.example' })
aw.seed('portalFeatures', featureDoc)
aw.seed('shopConfig', shopDoc(shop))
aw.seed('customers', { $id: KUNDE, name: 'Erika Muster', email: 'erika@brot.example' })
for (const p of produkte) aw.seed('shopProducts', p)
st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
K.leereCache()
ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
return ctx
}
beforeEach(() => {
st.calls.length = 0
config.publicBaseUrl = PORTAL
config.smtp.from = 'noreply@webklar.com'
config.shop.mode = 'on'
resetOutboxForTests()
sink = mailSink()
setMailTransportForTests(sink)
log = { warn: mock.method(console, 'warn', () => {}), error: mock.method(console, 'error', () => {}) }
K.leereCache()
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
})
const orders = () => [...aw.docs('shopOrders').values()]
const order = (id) => aw.docs('shopOrders').get(id)
const daten = (id) => JSON.parse(order(id).bestellung)
const produkt = (id) => JSON.parse(aw.docs('shopProducts').get(id).produkt)
const eintraege = () => [...aw.docs('termineOutbox').values()]
const shopStatus = () => JSON.parse(aw.docs('shopConfig').get(FEATURE).stripeStatus)
const SECRET = 'whsec_connect_test'
const PFAD = '/api/shop/stripe-webhook'
let nr = 0
const ereignis = (type, objekt, over = {}) => ({ id: `evt_${(nr += 1)}`, object: 'event', type, livemode: false, account: ACCOUNT, data: { object: objekt }, ...over })
const sende = (event, opt = {}) => postWebhook(event, { path: PFAD, secret: SECRET, ...opt })
const kunde = { name: 'Max Mustermann', email: 'max@example.com', phone: '+49 170 1234567', address: { line1: 'Rechnungsweg 1', line2: null, postal_code: '12345', city: 'Musterstadt', country: 'DE' } }
const versandAn = { name: 'Max Mustermann', address: { line1: 'Lieferweg 7', line2: null, postal_code: '67655', city: 'Kaiserslautern', country: 'DE', state: null } }
async function bestelle(positionen = [{ produktId: 'a1', menge: 2 }]) {
const r = await B.legeBestellungAn(ctx, { positionen, lieferart: 'versand', land: 'DE' }, { now: new Date() })
const doc = order(r.orderId)
return { ...r, doc, sessionId: doc.stripeSessionId }
}
const zahle = (b, felder = {}) =>
st.completeSession(ACCOUNT, b.sessionId, {
payment_status: 'paid',
amount_total: b.doc.summeCent,
customer_details: kunde,
shipping: versandAn,
payment_intent: 'pi_test1',
payment_method_types: ['card'],
...felder,
})
const secretVorher = config.shop.connectWebhookSecret
const keyVorher = config.stripe.secretKey
beforeEach(() => {
config.shop.connectWebhookSecret = SECRET
config.stripe.secretKey = keyVorher
})
after(() => {
config.shop.connectWebhookSecret = secretVorher
config.stripe.secretKey = keyVorher
})
test('Signatur: ungültig ⇒ 400, ohne Secret ⇒ 503, nichts geändert', async () => {
await aufbau()
const b = await bestelle()
const e = ereignis('checkout.session.completed', zahle(b))
assert.equal((await sende(e, { secret: 'whsec_falsch' })).status, 400)
assert.equal((await ohneSignatur(e)).status, 400)
assert.equal(order(b.orderId).status, 'offen')
config.shop.connectWebhookSecret = ''
assert.equal((await sende(e)).status, 503)
assert.equal(order(b.orderId).status, 'offen')
})
const ohneSignatur = (event) => send('POST', PFAD, { body: JSON.stringify(event) })
test('livemode: mit Live-Schlüssel werden Testereignisse ignoriert (200), Live-Ereignisse gelten', async () => {
await aufbau()
const b = await bestelle()
config.stripe.secretKey = 'sk_live_fake'
const session = zahle(b)
const test = await sende(ereignis('checkout.session.completed', session, { livemode: false }))
assert.equal(test.status, 200)
assert.deepEqual(test.body, { ignoriert: true })
assert.equal(order(b.orderId).status, 'offen')
const live = await sende(ereignis('checkout.session.completed', session, { livemode: true }))
assert.equal(live.status, 200)
assert.equal(order(b.orderId).status, 'bezahlt')
})
test('account.updated: speichert den Status des Shops mit diesem Konto, leert den Cache, behält den Hinweis nur wo sinnvoll', async () => {
await aufbau({ shop: { stripeStatus: JSON.stringify({ charges_enabled: false, hinweis: 'Klarna fehlt' }), einstellungen: JSON.stringify({ ...EINSTELLUNGEN, zahlungsarten: ['card', 'klarna'] }) } })
assert.equal(ctx.shop.stripeStatus.charges_enabled, false)
const konto = { id: ACCOUNT, charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [], disabled_reason: null } }
const r = await sende(ereignis('account.updated', konto))
assert.equal(r.status, 200)
const s = shopStatus()
assert.equal(s.charges_enabled, true)
assert.equal(s.payouts_enabled, true)
assert.equal(s.hinweis, 'Klarna fehlt', 'Hinweis bleibt: Klarna angeboten und Konto aktiv')
assert.ok(s.geprueftAt)
const frisch = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
assert.equal(frisch.shop.stripeStatus.charges_enabled, true, 'Cache wurde geleert')
// nur Karte ⇒ Hinweis entfällt
await updateDocument(config.collections.shopConfig, FEATURE, { einstellungen: JSON.stringify(EINSTELLUNGEN) })
await sende(ereignis('account.updated', { ...konto, charges_enabled: false }))
assert.equal(shopStatus().hinweis, undefined)
assert.equal(shopStatus().charges_enabled, false)
// Konto ohne Ereignis-Feld account: Objekt-ID zählt; unbekanntes Konto ⇒ 200 ohne Wirkung
const vorher = JSON.stringify(shopStatus())
assert.equal((await sende(ereignis('account.updated', { id: 'acct_unbekannt', charges_enabled: true }, { account: 'acct_unbekannt' }))).status, 200)
assert.equal((await sende(ereignis('account.updated', { id: ACCOUNT, charges_enabled: true, payouts_enabled: false }, { account: undefined }))).status, 200)
assert.equal(shopStatus().charges_enabled, true)
assert.notEqual(JSON.stringify(shopStatus()), vorher)
})
test('checkout.session.completed (bezahlt) ⇒ bezahlt, Bestand, Mails; Wiederholung ändert nichts', async () => {
await aufbau()
const b = await bestelle()
const e = ereignis('checkout.session.completed', zahle(b))
const r = await sende(e)
assert.equal(r.status, 200)
assert.equal(order(b.orderId).status, 'bezahlt')
assert.equal(order(b.orderId).paymentIntentId, 'pi_test1')
assert.equal(produkt('a1').bestand, 3)
await warteAufVersand()
assert.equal(sink.sent.length, 2)
assert.equal((await sende(e)).status, 200)
assert.equal((await Promise.all([sende(e), sende(e)])).every((x) => x.status === 200), true)
await warteAufVersand()
assert.equal(produkt('a1').bestand, 3)
assert.equal(sink.sent.length, 2)
assert.equal(daten(b.orderId).verlauf.filter((v) => v.was === 'bezahlt').length, 1)
})
test('SEPA: completed unbezahlt lässt die Bestellung offen und schickt einmal die Eingangsmail G15, async_payment_succeeded bucht', async () => {
await aufbau()
const b = await bestelle()
const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] })
assert.equal((await sende(ereignis('checkout.session.completed', unbezahlt))).status, 200)
assert.equal(order(b.orderId).status, 'offen')
assert.equal(produkt('a1').bestand, 5)
await warteAufVersand()
assert.deepEqual(sink.sent.map((m) => [m.to[0].address, m.subject.startsWith('Wir haben Ihre Bestellung')]), [['max@example.com', true]], 'nur die Eingangsmail an den Käufer')
assert.ok(daten(b.orderId).mailsEingangAt, 'Marker gesetzt')
assert.equal((await sende(ereignis('checkout.session.completed', unbezahlt))).status, 200)
await warteAufVersand()
assert.equal(sink.sent.length, 1, 'Wiederholung des Ereignisses: keine zweite Eingangsmail')
const bezahlt = { ...unbezahlt, payment_status: 'paid' }
assert.equal((await sende(ereignis('checkout.session.async_payment_succeeded', bezahlt))).status, 200)
assert.equal(order(b.orderId).status, 'bezahlt')
assert.equal(produkt('a1').bestand, 3)
})
test('async_payment_failed ⇒ storniert, Bestand frei, G14 eingereiht; Wiederholung nur einmal', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] })
const e = ereignis('checkout.session.async_payment_failed', session)
assert.equal((await sende(e)).status, 200)
assert.equal(order(b.orderId).status, 'storniert')
assert.deepEqual(daten(b.orderId).verlauf.map((v) => v.was), ['angelegt', 'zahlung_fehlgeschlagen'])
assert.equal((await B.reserviert(FEATURE, new Date())).get('a1'), undefined)
await warteAufVersand()
assert.deepEqual(eintraege().map((x) => x.art), ['G14'])
assert.equal(eintraege()[0].bookingId, b.orderId)
assert.equal((await sende(e)).status, 200)
await warteAufVersand()
assert.equal(eintraege().length, 1)
})
test('checkout.session.expired ⇒ storniert (abgelaufen), keine Mail; bezahlte Bestellung bleibt', async () => {
await aufbau()
const a = await bestelle()
const e = ereignis('checkout.session.expired', { ...st.accountSessions(ACCOUNT).get(a.sessionId), status: 'expired' })
assert.equal((await sende(e)).status, 200)
assert.equal(order(a.orderId).status, 'storniert')
assert.deepEqual(daten(a.orderId).verlauf.map((v) => v.was), ['angelegt', 'abgelaufen'])
await warteAufVersand()
assert.equal(eintraege().length, 0)
const c = await bestelle([{ produktId: 'a1', menge: 1 }])
await sende(ereignis('checkout.session.completed', zahle(c)))
assert.equal((await sende(ereignis('checkout.session.expired', st.accountSessions(ACCOUNT).get(c.sessionId)))).status, 200)
assert.equal(order(c.orderId).status, 'bezahlt')
})
test('Ereignis eines fremden Kontos kann keine Bestellung eines anderen Shops berühren', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b)
for (const type of ['checkout.session.completed', 'checkout.session.async_payment_succeeded']) {
assert.equal((await sende(ereignis(type, session, { account: 'acct_fremd' }))).status, 200)
}
assert.equal((await sende(ereignis('checkout.session.async_payment_failed', session, { account: 'acct_fremd' }))).status, 200)
assert.equal((await sende(ereignis('checkout.session.expired', session, { account: 'acct_fremd' }))).status, 200)
assert.equal((await sende(ereignis('checkout.session.completed', session, { account: undefined }))).status, 200)
assert.equal(order(b.orderId).status, 'offen')
assert.equal(produkt('a1').bestand, 5)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
assert.equal(eintraege().length, 0)
})
test('Sitzungen ohne wkShop und unbekannte Ereignisse ⇒ 200 ohne Wirkung', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b)
assert.equal((await sende(ereignis('checkout.session.completed', { ...session, metadata: { orderId: b.orderId } }))).status, 200)
assert.equal((await sende(ereignis('checkout.session.completed', { ...session, metadata: {} }))).status, 200)
assert.equal((await sende(ereignis('checkout.session.completed', { id: 'cs_x', payment_status: 'paid' }))).status, 200)
assert.equal((await sende(ereignis('payout.paid', { id: 'po_1' }))).status, 200)
assert.equal((await sende(ereignis('charge.refunded', { id: 'ch_1', payment_intent: null }))).status, 200)
assert.equal(order(b.orderId).status, 'offen')
})
test('Fehler bei der Verarbeitung ⇒ 500 (Stripe wiederholt), Log ohne Kundendaten', async () => {
await aufbau()
const b = await bestelle()
const e = ereignis('checkout.session.completed', zahle(b))
aw.failNext({ collection: 'shopOrders', method: 'GET', status: 500 })
const nach500 = await sende(e)
assert.equal(nach500.status, 500)
const logs = log.error.mock.calls.map((a) => a.arguments.map(String).join(' ')).join('\n')
assert.match(logs, /checkout\.session\.completed/)
assert.doesNotMatch(logs, /max@example|Mustermann/)
assert.equal((await sende(e)).status, 200)
assert.equal(order(b.orderId).status, 'bezahlt')
})
async function bezahlteBestellung(positionen) {
const b = await bestelle(positionen)
await sende(ereignis('checkout.session.completed', zahle(b)))
assert.equal(order(b.orderId).status, 'bezahlt')
return b
}
const refund = (id, amount, extra = {}) => ({ id, object: 'refund', amount, currency: 'eur', status: 'succeeded', payment_intent: 'pi_test1', ...extra })
const charge = (b, refunds, extra = {}) => ({ id: 'ch_1', object: 'charge', payment_intent: 'pi_test1', amount: b.doc.summeCent, metadata: { wkShop: '1', orderId: b.orderId }, ...(refunds ? { refunds: { object: 'list', data: refunds } } : {}), ...extra })
test('charge.refunded: Teilerstattung wird nachgetragen, Wiederholung und Portal-Eintrag zählen nicht doppelt, volle ⇒ erstattet', async () => {
await aufbau()
const b = await bezahlteBestellung()
const summe = b.doc.summeCent
await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500)])))
assert.equal(order(b.orderId).status, 'bezahlt')
let e = daten(b.orderId).erstattungen
assert.equal(e.length, 1)
assert.deepEqual({ rid: e[0].rid, betragCent: e[0].betragCent, grund: e[0].grund, quelle: e[0].quelle }, { rid: 're_1', betragCent: 500, grund: 'Stripe-Dashboard', quelle: 'stripe' })
assert.ok(e[0].at)
// Wiederholung + bereits über das Portal gebuchte Erstattung (anderes Feld, gleiche rid)
await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500)])))
assert.equal(daten(b.orderId).erstattungen.length, 1)
const d = daten(b.orderId)
await updateDocument(config.collections.shopOrders, b.orderId, { bestellung: JSON.stringify({ ...d, erstattungen: [...d.erstattungen, { rid: 're_portal', betragCent: 100, grund: 'Kaputt', at: 'x' }] }) })
await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500), refund('re_portal', 100)])))
assert.equal(daten(b.orderId).erstattungen.length, 2)
// Rest ⇒ voll erstattet
await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500), refund('re_portal', 100), refund('re_2', summe - 600)])))
assert.equal(order(b.orderId).status, 'erstattet')
assert.equal(daten(b.orderId).erstattungen.length, 3)
assert.deepEqual(daten(b.orderId).verlauf.filter((v) => v.was === 'erstattet_stripe').map((v) => v.betragCent), [500, summe - 600])
await warteAufVersand()
assert.equal(eintraege().filter((x) => x.art === 'G13').length, 0, 'keine Käufermail bei Dashboard-Erstattung')
// spätere Wiederholung ändert den Endzustand nicht
await sende(ereignis('charge.refunded', charge(b, [refund('re_2', summe - 600)])))
assert.equal(order(b.orderId).status, 'erstattet')
assert.equal(daten(b.orderId).erstattungen.length, 3)
})
test('charge.refunded: ohne refunds im Ereignis werden sie bei Stripe gelesen; Bestellung ohne Metadaten wird über die Zahlung gefunden', async () => {
await aufbau()
const b = await bezahlteBestellung()
st.setPaymentIntent(ACCOUNT, 'pi_test1', { amount: b.doc.summeCent })
const r = await st.refunds(ACCOUNT)
r.set('re_s1', refund('re_s1', b.doc.summeCent))
await sende(ereignis('charge.refunded', charge(b, null, { metadata: {} })))
assert.equal(order(b.orderId).status, 'erstattet')
assert.equal(daten(b.orderId).erstattungen[0].rid, 're_s1')
assert.ok(st.calls.some((c) => c.path === '/v1/refunds' && c.account === ACCOUNT))
})
test('charge.refunded: fremdes Konto, offene Bestellung, fehlgeschlagene Refunds ändern nichts', async () => {
await aufbau()
const b = await bezahlteBestellung()
await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500)]), { account: 'acct_fremd' }))
await sende(ereignis('charge.refunded', charge(b, [refund('re_f', 500, { status: 'failed' }), refund('re_n', 0)])))
assert.equal(daten(b.orderId).erstattungen, undefined)
const offen = await bestelle([{ produktId: 'a1', menge: 1 }])
await sende(ereignis('charge.refunded', charge(offen, [refund('re_o', 100)], { payment_intent: 'pi_offen' })))
assert.equal(order(offen.orderId).status, 'offen')
assert.equal(daten(offen.orderId).erstattungen, undefined)
})
+372
View File
@@ -0,0 +1,372 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Webshop E5: Widerrufe (§ 356a BGB, services/shop/widerrufe.js). Mails landen in der Warteschlange (termineOutbox) bzw. der
// Mailsenke; nichts verlässt den Prozess.
const aw = await startAppwriteMock({
schema: {
termineOutbox: schemaKeys('termineOutbox'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
},
datetimes: { ...TERMINE_DATETIMES, ...SHOP_DATETIMES },
defaultLimit: 25,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const W = await import('../server/services/shop/widerrufe.js')
const { normalisiereWiderruf, erfasseWiderruf, listeWiderrufe, markiereBearbeitet } = W
after(() => setMailTransportForTests(null))
const JETZT = new Date('2026-10-05T12:03:00.000Z') // 14:03 Berlin (MESZ)
const nach = (ms) => new Date(JETZT.getTime() + ms)
const SITE_KEY = 'abcdefghijklmnop'
const BASIS_VORHER = config.publicBaseUrl
const ABSENDER_VORHER = config.smtp.from
const ctxMit = (state = 'active', id = 'f_shop01') => ({
doc: { $id: id, customerId: 'c1', projectId: 'p1', featureKey: 'shop', siteKey: SITE_KEY, state },
shop: {
id,
customerId: 'c1',
projectId: 'p1',
siteKey: SITE_KEY,
geoeffnet: false,
einstellungen: {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
kleinunternehmer: true,
lieferzeit: '2–4 Werktage',
shopname: 'Töpferei Muster',
anbieter: { name: 'Erika Muster', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: 'DE', email: 'info@toepferei.example', telefon: '', ustId: '' },
},
rechtstexte: { entwurf: {}, bestaetigt: { texte: { agb: 'AGB', widerrufsbelehrung: 'Belehrung', widerrufsformular: 'Formular', impressumUrl: '', datenschutzUrl: '' }, hash: 'h1', at: JETZT.toISOString(), von: 'c1' } },
},
})
const EINGABE = { name: 'Max Mustermann', email: 'Max@Example.com', bestellnummer: '26-k7qm2x', nachricht: 'Ich widerrufe den Vertrag über die Tasse.' }
const bestellung = (over = {}) => ({
$id: 'o_bestellung01',
shopId: 'f_shop01',
customerId: 'c1',
bestellnummer: '26-K7QM2X',
status: 'bezahlt',
kaeuferEmail: 'max@example.com',
summeCent: 1200,
erstelltAt: JETZT.toISOString(),
bezahltAt: JETZT.toISOString(),
bestellung: JSON.stringify({ positionen: [{ name: 'Tasse', menge: 1, summeCent: 1200, widerruf: 'gesetzlich' }], verlauf: [{ at: JETZT.toISOString(), was: 'angelegt' }] }),
...over,
})
let sink
let log
beforeEach(async () => {
for (const sammlung of ['termineOutbox', 'customers', 'shopOrders', 'shopWiderrufe']) {
for (const id of aw.docs(sammlung).keys()) await deleteDocument(config.collections[sammlung], id)
}
resetOutboxForTests()
resetTermineLimitsForTests()
config.smtp.from = 'noreply@webklar.com'
config.publicBaseUrl = 'https://project.webklar.com'
sink = mailSink()
setMailTransportForTests(sink)
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
aw.seed('customers', { $id: 'c1', name: 'Erika Muster', email: 'erika@toepferei.example' })
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
config.smtp.from = ABSENDER_VORHER
config.publicBaseUrl = BASIS_VORHER
})
const widerrufe = () => [...aw.docs('shopWiderrufe').values()]
const outbox = () => [...aw.docs('termineOutbox').values()]
/** Zahl der G12 in der Warteschlange zu Widerrufen mit dieser E-Mail-Adresse (ohne passende Bestellung gilt die Widerruf-ID als bookingId) */
const g12An = (email) => {
const ids = new Set(widerrufe().filter((d) => d.email === email).map((d) => d.$id))
return outbox().filter((e) => e.art === 'G12' && ids.has(e.bookingId)).length
}
const fehlerVon = async (promise) => {
try {
await promise
} catch (err) {
return err
}
assert.fail('hätte werfen müssen')
}
// ---------------------------------------------------------------- Prüfung
test('normalisiereWiderruf: gültige Eingabe wird bereinigt (E-Mail klein, Bestellnummer groß, Steuerzeichen raus)', () => {
const erg = normalisiereWiderruf({ name: ' Max \u0000 Mustermann\n', email: ' Max@Example.com ', bestellnummer: ' 26-k7qm2x ', nachricht: 'Zeile 1\r\nZeile\u0007 2\u0000 ' })
assert.deepEqual(erg, { widerruf: { name: 'Max Mustermann', email: 'max@example.com', bestellnummer: '26-K7QM2X', nachricht: 'Zeile 1\nZeile 2' } })
assert.equal(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: 'X' }).widerruf.nachricht, '', 'Nachricht ist freiwillig')
})
test('normalisiereWiderruf: Bestellnummer freiwillig; ohne Nummer ist die Erklärung Pflicht (§ 356a BGB)', () => {
const ohneNummer = normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: ' ', nachricht: 'Bestellt am 01.10., Tasse blau' })
assert.deepEqual(ohneNummer, { widerruf: { name: 'Ab', email: 'a@b.de', bestellnummer: '', nachricht: 'Bestellt am 01.10., Tasse blau' } })
assert.equal(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de' }).fehler.nachricht, 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.')
assert.equal(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', nachricht: ' \n ' }).fehler.nachricht, 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.')
assert.equal(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: '26-K7QM2X' }).fehler, undefined, 'mit Nummer bleibt die Erklärung freiwillig')
assert.ok(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: 'a b', nachricht: 'x' }).fehler.bestellnummer, 'gegebene Nummer: gleiche Formatregel')
})
test('erfasseWiderruf ohne Bestellnummer: gespeichert ohne Zuordnung, G12 und K26 mit „–“', async () => {
aw.seed('shopOrders', bestellung())
const erg = await erfasseWiderruf(ctxMit(), { ...EINGABE, bestellnummer: '', nachricht: 'Bestellt am 01.10., Tasse blau' }, { now: JETZT, ip: '203.0.113.77' })
assert.equal(erg.ok, true)
const d = widerrufe().find((x) => x.$id === erg.id)
assert.equal(d.bestellnummer, '')
assert.equal(d.orderId, '')
await warteAufVersand()
assert.equal(outbox().filter((e) => e.art === 'G12').length, 1)
assert.equal(JSON.parse(aw.docs('shopOrders').get('o_bestellung01').bestellung).verlauf.length, 1, 'ohne Nummer keine Zuordnung zum Verlauf')
await assert.rejects(
() => erfasseWiderruf(ctxMit(), { ...EINGABE, bestellnummer: '', nachricht: '' }, { now: JETZT, ip: '203.0.113.78' }),
(err) => err.status === 400 && err.fehler.nachricht === 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.',
)
})
test('normalisiereWiderruf: Fehler je Feld in Sie-Form', () => {
const grenzen = normalisiereWiderruf({ name: 'M', email: 'keine-adresse', bestellnummer: '', nachricht: 'x'.repeat(2001) })
assert.deepEqual(Object.keys(grenzen.fehler).sort(), ['email', 'nachricht', 'name'], 'leere Bestellnummer ist erlaubt')
for (const text of Object.values(grenzen.fehler)) assert.match(text, /Bitte|darf/)
assert.equal(normalisiereWiderruf({ name: 'x'.repeat(201), email: 'a@b.de', bestellnummer: '1' }).fehler.name.includes('200'), true)
assert.ok(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: 'x'.repeat(33) }).fehler.bestellnummer)
assert.ok(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: '26_K7 QM' }).fehler.bestellnummer, 'nur Buchstaben, Ziffern, Bindestrich')
assert.ok(normalisiereWiderruf({ name: 'Ab', email: `${'a'.repeat(250)}@b.de`, bestellnummer: '1' }).fehler.email, 'über 254 Zeichen')
assert.equal(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: '1', nachricht: 'x'.repeat(2000) }).fehler, undefined)
// mehrere Adressen / Namensform / eingeschmuggelter Zeilenumbruch
for (const email of ['a@b.de, c@d.de', 'a@b.de;c@d.de', 'Max <a@b.de>', 'a@b.de\nc@d.de', 'a b@c.de', 'a@b']) {
assert.ok(normalisiereWiderruf({ name: 'Ab', email, bestellnummer: '1' }).fehler?.email, email)
}
assert.deepEqual(Object.keys(normalisiereWiderruf(null).fehler).sort(), ['email', 'nachricht', 'name'])
assert.ok(normalisiereWiderruf({ name: 5, email: {}, bestellnummer: [] }).fehler.name, 'falsche Typen sind leer')
})
// ---------------------------------------------------------------- Erfassen
test('passende Bestellung: orderId gesetzt, Verlauf ergänzt, G12 in der Warteschlange mit Datum und Uhrzeit, K26 an den Händler', async () => {
aw.seed('shopOrders', bestellung())
const erg = await erfasseWiderruf(ctxMit(), EINGABE, { now: JETZT, ip: '203.0.113.5' })
await warteAufVersand()
assert.equal(erg.ok, true)
assert.equal(erg.eingang, '05.10.2026, 14:03 Uhr')
const [doc] = widerrufe()
assert.equal(erg.id, doc.$id)
assert.deepEqual(
{ shopId: doc.shopId, customerId: doc.customerId, bestellnummer: doc.bestellnummer, name: doc.name, email: doc.email, orderId: doc.orderId },
{ shopId: 'f_shop01', customerId: 'c1', bestellnummer: '26-K7QM2X', name: 'Max Mustermann', email: 'max@example.com', orderId: 'o_bestellung01' },
)
assert.equal(doc.nachricht, EINGABE.nachricht)
assert.equal(new Date(doc.eingangAt).getTime(), JETZT.getTime())
assert.equal(new Date(doc.bestaetigungAt).getTime(), JETZT.getTime(), 'Warteschlange hat G12 angenommen')
const verlauf = JSON.parse(aw.docs('shopOrders').get('o_bestellung01').bestellung).verlauf
assert.deepEqual(verlauf.map((v) => v.was), ['angelegt', 'widerruf'])
assert.equal(verlauf[1].at, JETZT.toISOString())
// G12 an den Käufer (Warteschlange): bookingId = Bestell-ID (mails.js bevorzugt sie vor der Widerruf-ID), Absender Shopname, Antwort an den Händler
const [g12] = outbox()
assert.equal(g12.art, 'G12')
assert.equal(g12.featureId, 'f_shop01')
assert.equal(g12.bookingId, 'o_bestellung01')
assert.equal(g12.fromName, 'Töpferei Muster')
assert.equal(g12.replyTo, 'info@toepferei.example')
const kaeufer = sink.sent.find((m) => m.to.some((t) => t.address === 'max@example.com'))
assert.ok(kaeufer, 'G12 versandt')
for (const teil of ['26-K7QM2X', 'Max Mustermann', '05.10.2026, 14:03 Uhr', 'Ich widerrufe den Vertrag über die Tasse.']) assert.ok(kaeufer.text.includes(teil), teil)
// K26 an den Händler, ohne BCC
const haendler = sink.sent.find((m) => m.to.some((t) => t.address === 'erika@toepferei.example'))
assert.ok(haendler, 'K26 versandt')
assert.ok(!haendler.bcc)
for (const teil of ['Max Mustermann', 'max@example.com', '26-K7QM2X', '05.10.2026, 14:03 Uhr']) assert.ok(haendler.text.includes(teil), teil)
assert.equal(sink.sent.length, 2)
})
test('keine passende Bestellung (falsche Nummer, fremde E-Mail, anderer Shop): trotzdem gespeichert und bestätigt, ohne orderId und ohne Verlauf', async () => {
aw.seed('shopOrders', bestellung())
aw.seed('shopOrders', bestellung({ $id: 'o_fremd', shopId: 'f_anderer', bestellnummer: '26-FREMD1', kaeuferEmail: 'max@example.com' }))
const faelle = [
{ bestellnummer: '26-ZZZZZZ' },
{ email: 'jemand@example.com' },
{ bestellnummer: '26-FREMD1' }, // existiert nur in einem anderen Shop
]
for (const [i, abweichung] of faelle.entries()) {
const erg = await erfasseWiderruf(ctxMit(), { ...EINGABE, ...abweichung }, { now: nach(i * 1000), ip: `203.0.113.${i + 10}` })
assert.equal(erg.ok, true)
assert.equal(widerrufe().find((d) => d.$id === erg.id).orderId, '')
}
await warteAufVersand()
assert.equal(JSON.parse(aw.docs('shopOrders').get('o_bestellung01').bestellung).verlauf.length, 1)
assert.equal(JSON.parse(aw.docs('shopOrders').get('o_fremd').bestellung).verlauf.length, 1)
assert.equal(outbox().length, 3)
assert.ok(outbox().every((e) => e.art === 'G12'))
assert.deepEqual(outbox().map((e) => e.bookingId).sort(), widerrufe().map((d) => d.$id).sort(), 'ohne Bestellung gilt die Widerruf-ID')
assert.equal(widerrufe().every((d) => d.bestaetigungAt), true, 'Bestätigung auch ohne passende Bestellung')
})
test('E-Mail-Vergleich ohne Beachtung der Groß-/Kleinschreibung; anonymisierte Bestellung (leere E-Mail) passt nie', async () => {
aw.seed('shopOrders', bestellung({ kaeuferEmail: 'MAX@Example.COM' }))
const erg = await erfasseWiderruf(ctxMit(), EINGABE, { now: JETZT, ip: '203.0.113.5' })
assert.equal(widerrufe().find((d) => d.$id === erg.id).orderId, 'o_bestellung01')
await deleteDocument(config.collections.shopOrders, 'o_bestellung01')
aw.seed('shopOrders', bestellung({ kaeuferEmail: '' }))
const erg2 = await erfasseWiderruf(ctxMit(), { ...EINGABE, email: 'x@example.com' }, { now: nach(1000), ip: '203.0.113.5' })
assert.equal(widerrufe().find((d) => d.$id === erg2.id).orderId, '')
})
test('ungültige Eingabe: Fehler 400 mit Feldtexten, nichts gespeichert, keine Mail', async () => {
const err = await fehlerVon(erfasseWiderruf(ctxMit(), { ...EINGABE, name: '' }, { now: JETZT, ip: '203.0.113.5' }))
assert.equal(err.status, 400)
assert.ok(err.fehler.name)
assert.equal(widerrufe().length, 0)
assert.equal(outbox().length, 0)
})
test('Grenzen: 10 je IP und Stunde ⇒ 429, danach wieder frei; 20 je Shop und Stunde; andere Shops unberührt', async () => {
const ip = '203.0.113.7'
for (let i = 0; i < 10; i += 1) await erfasseWiderruf(ctxMit(), EINGABE, { now: nach(i * 1000), ip })
const err = await fehlerVon(erfasseWiderruf(ctxMit(), EINGABE, { now: nach(11000), ip }))
assert.equal(err.status, 429)
assert.equal(err.message, 'Zu viele Versuche. Bitte versuchen Sie es später noch einmal.')
assert.equal(widerrufe().length, 10)
// eine andere IP geht noch
await erfasseWiderruf(ctxMit(), EINGABE, { now: nach(12000), ip: '203.0.113.8' })
// nach einer Stunde ist die IP wieder frei
await erfasseWiderruf(ctxMit(), EINGABE, { now: nach(61 * 60 * 1000), ip })
// 20 je Shop: bisher 12 Versuche in der ersten Stunde (11 + 1 in der zweiten) ⇒ in der zweiten Stunde weiter mit wechselnden IPs
resetTermineLimitsForTests()
const t = nach(3 * 60 * 60 * 1000)
for (let i = 0; i < 20; i += 1) await erfasseWiderruf(ctxMit(), EINGABE, { now: t, ip: `198.51.100.${i + 1}` })
const shopErr = await fehlerVon(erfasseWiderruf(ctxMit(), EINGABE, { now: t, ip: '198.51.100.99' }))
assert.equal(shopErr.status, 429)
await erfasseWiderruf(ctxMit('active', 'f_shop02'), EINGABE, { now: t, ip: '198.51.100.99' })
})
test('M3: G12 geht je Empfängeradresse höchstens 3 Mal pro Stunde raus; der Widerruf wird trotzdem gespeichert und mit eingang beantwortet', async () => {
const ziel = { ...EINGABE, email: 'opfer@example.com' }
const antworten = []
for (let i = 0; i < 5; i += 1) antworten.push(await erfasseWiderruf(ctxMit(), ziel, { now: nach(i * 1000), ip: `203.0.113.${100 + i}` }))
assert.ok(antworten.every((a) => a.ok === true && typeof a.eingang === 'string' && a.eingang !== ''))
assert.equal(widerrufe().filter((d) => d.email === 'opfer@example.com').length, 5, 'alle Widerrufe gespeichert')
await warteAufVersand()
assert.equal(g12An('opfer@example.com'), 3)
const mitBestaetigung = widerrufe().filter((d) => d.email === 'opfer@example.com' && d.bestaetigungAt).length
assert.equal(mitBestaetigung, 3)
// eine andere Adresse ist nicht betroffen
await erfasseWiderruf(ctxMit(), { ...EINGABE, email: 'andere@example.com' }, { now: nach(6000), ip: '203.0.113.120' })
await warteAufVersand()
assert.equal(g12An('andere@example.com'), 1)
// nach einer Stunde geht wieder eine Bestaetigung raus
await erfasseWiderruf(ctxMit(), ziel, { now: nach(61 * 60 * 1000), ip: '203.0.113.130' })
await warteAufVersand()
assert.equal(g12An('opfer@example.com'), 4)
})
test('ein ungültiger Versuch zählt gegen die Grenze (kein Raten ohne Limit)', async () => {
const ip = '203.0.113.9'
for (let i = 0; i < 10; i += 1) await fehlerVon(erfasseWiderruf(ctxMit(), { ...EINGABE, email: 'x' }, { now: JETZT, ip }))
assert.equal((await fehlerVon(erfasseWiderruf(ctxMit(), EINGABE, { now: JETZT, ip }))).status, 429)
})
test('Mailfehler kippen die Anfrage nicht: Warteschlange verweigert G12 ⇒ ok, bestaetigungAt leer; K26-Versand scheitert ⇒ ok', async () => {
// G12: die Warteschlange verweigert den Eintrag (fehlende featureId)
const ohneFeature = ctxMit()
ohneFeature.doc.$id = ''
const erg = await erfasseWiderruf(ohneFeature, EINGABE, { now: JETZT, ip: '203.0.113.5' })
assert.equal(erg.ok, true)
assert.equal(erg.eingang, '05.10.2026, 14:03 Uhr')
const doc = widerrufe().find((d) => d.$id === erg.id)
assert.equal(doc.bestaetigungAt ?? '', '', 'nicht bestätigt, weil nichts eingereiht wurde')
assert.equal(outbox().length, 0)
await warteAufVersand()
// K26: SMTP wirft
setMailTransportForTests({ sendMail: async () => Promise.reject(Object.assign(new Error('smtp: max@example.com abgelehnt'), { code: 'ECONNECTION' })) })
const erg2 = await erfasseWiderruf(ctxMit(), EINGABE, { now: nach(1000), ip: '203.0.113.5' })
assert.equal(erg2.ok, true)
await warteAufVersand()
assert.ok(widerrufe().find((d) => d.$id === erg2.id).bestaetigungAt, 'G12 war eingereiht')
assert.ok(!log.warn.mock.calls.some((a) => a.arguments.map(String).join(' ').includes('max@example.com')), 'keine Adresse im Log')
})
test('K26 ohne Kunden-E-Mail scheitert still, Widerruf und G12 bleiben', async () => {
for (const id of aw.docs('customers').keys()) await deleteDocument(config.collections.customers, id)
const erg = await erfasseWiderruf(ctxMit(), EINGABE, { now: JETZT, ip: '203.0.113.5' })
assert.equal(erg.ok, true)
await warteAufVersand()
assert.equal(outbox().length, 1)
})
test('geschlossener oder deaktivierter Shop: Widerruf wird trotzdem angenommen', async () => {
for (const [i, state] of ['active', 'disabled'].entries()) {
const ctx = ctxMit(state)
ctx.shop.geoeffnet = false
const erg = await erfasseWiderruf(ctx, EINGABE, { now: nach(i * 1000), ip: `203.0.113.${20 + i}` })
assert.equal(erg.ok, true, state)
}
await warteAufVersand()
assert.equal(widerrufe().length, 2)
assert.equal(outbox().length, 2)
})
// ---------------------------------------------------------------- Verwalten
test('listeWiderrufe: neueste zuerst, 50 je Seite, nur der eigene Shop', async () => {
for (let i = 0; i < 53; i += 1) {
aw.seed('shopWiderrufe', { $id: `w_${String(i).padStart(3, '0')}`, shopId: 'f_shop01', customerId: 'c1', bestellnummer: `26-N${i}`, name: 'Max', email: 'max@example.com', nachricht: '', eingangAt: nach(i * 60000).toISOString() })
}
aw.seed('shopWiderrufe', { $id: 'w_fremd', shopId: 'f_shop02', customerId: 'c2', bestellnummer: '26-X', name: 'Fremd', email: 'f@example.com', nachricht: '', eingangAt: nach(999 * 60000).toISOString() })
const s1 = await listeWiderrufe('f_shop01')
assert.equal(s1.widerrufe.length, 50)
assert.equal(s1.mehr, true)
assert.equal(s1.seite, 1)
assert.equal(s1.widerrufe[0].id, 'w_052')
assert.equal(s1.widerrufe[49].id, 'w_003')
assert.ok(!s1.widerrufe.some((w) => w.id === 'w_fremd'))
const s2 = await listeWiderrufe('f_shop01', { seite: 2 })
assert.deepEqual(s2.widerrufe.map((w) => w.id), ['w_002', 'w_001', 'w_000'])
assert.equal(s2.mehr, false)
assert.deepEqual((await listeWiderrufe('f_shop01', { seite: 3 })).widerrufe, [])
assert.deepEqual((await listeWiderrufe('f_leer')).widerrufe, [])
})
test('markiereBearbeitet: setzt bearbeitetAt einmal; fremder Shop oder unbekannte ID ⇒ 404 und keine Änderung', async () => {
aw.seed('shopWiderrufe', { $id: 'w_eigen', shopId: 'f_shop01', customerId: 'c1', bestellnummer: '26-A', name: 'Max', email: 'max@example.com', nachricht: '', eingangAt: JETZT.toISOString() })
aw.seed('shopWiderrufe', { $id: 'w_fremd', shopId: 'f_shop02', customerId: 'c2', bestellnummer: '26-B', name: 'Fremd', email: 'f@example.com', nachricht: '', eingangAt: JETZT.toISOString() })
const fremd = await fehlerVon(markiereBearbeitet('f_shop01', 'w_fremd', { now: nach(1000) }))
assert.equal(fremd.status, 404)
assert.ok(!aw.docs('shopWiderrufe').get('w_fremd').bearbeitetAt)
for (const id of ['w_gibtsnicht', '../x', '', null]) assert.equal((await fehlerVon(markiereBearbeitet('f_shop01', id, { now: nach(1000) }))).status, 404, String(id))
const erg = await markiereBearbeitet('f_shop01', 'w_eigen', { now: nach(2000) })
assert.equal(erg.id, 'w_eigen')
assert.equal(new Date(erg.bearbeitetAt).getTime(), nach(2000).getTime())
const nochmal = await markiereBearbeitet('f_shop01', 'w_eigen', { now: nach(9000) })
assert.equal(new Date(nochmal.bearbeitetAt).getTime(), nach(2000).getTime(), 'zweiter Aufruf ändert nichts')
assert.equal((await listeWiderrufe('f_shop01')).widerrufe[0].bearbeitetAt, erg.bearbeitetAt)
})
+51
View File
@@ -0,0 +1,51 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
// Task G1: der Webshop ist im Kundenbereich verdrahtet (dashboard.html, app.js, funktionen.js, shop.css, shop.js). Rein statisch, ohne DOM.
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
test('dashboard.html: shop.css nach termine.css, Abschnitt #view-webshop mit Kopf „Webshop“ und #webshop-body', () => {
const html = lies('public/dashboard.html')
assert.match(html, /<link rel="stylesheet" href="\/termine\.css">\s*<link rel="stylesheet" href="\/shop\.css">/)
assert.match(html, /<section class="kb-view" id="view-webshop">[\s\S]*?<h1>Webshop<\/h1>[\s\S]*?<div id="webshop-body">[\s\S]*?<\/section>/)
assert.equal([...html.matchAll(/id="view-webshop"/g)].length, 1)
assert.doesNotMatch(html, /data-view="webshop"/, 'kein eigener Navigationseintrag: Unterseite von Funktionen')
})
test('app.js: VIEW_HASHES, viewRefreshers, FEATURE_LABELS und APP_PARENT kennen den Webshop', () => {
const app = lies('public/app.js')
assert.match(app, /VIEW_HASHES = \{[^}]*webshop: 'webshop'/)
assert.match(app, /FEATURE_LABELS = \{[^}]*shop: 'Webshop'/)
assert.match(app, /webshop: \(\) => import\('\/shop\.js'\)\.then\(\(m\) => m\.initShop\(document\.getElementById\('webshop-body'\)\)\)/)
// APP_PARENT: die Reihenfolge der bisherigen Einträge bleibt (termineView.test.js), webshop steht am Ende
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', /)
assert.match(app, /APP_PARENT = \{[^}]*projekt: 'projects', webshop: 'funktionen' \}/)
// die Ansicht ist vom Hash aus erreichbar: kein zweiter Schlüssel mit demselben Hash
const hashes = app.match(/VIEW_HASHES = \{([^}]*)\}/)[1]
assert.equal([...hashes.matchAll(/'webshop'/g)].length, 1)
})
test('shop.js und shop.css: die Hülle exportiert initShop, die Styles hängen an #view-webshop und sh-', () => {
const js = lies('public/shop.js')
assert.match(js, /export function initShop\(root, \{ projectId \} = \{\}\)/)
assert.doesNotMatch(js.replace(/\/\*[\s\S]*?\*\//g, ''), /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, 'Text nie als HTML')
const css = lies('public/shop.css')
assert.match(css, /#view-webshop/)
assert.doesNotMatch(css, /(^|[}\s,])\.tm-/m, 'keine Klassen des Terminplaners neu definieren')
})
test('funktionen.js: Webshop-Kachel inline, die erlaubten Importe bleiben (zwei absolute, ein relativer, kein dynamischer)', () => {
const fn = lies('public/funktionen.js')
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/fn-util.js'", "from '/analyse-view.js'"])
assert.deepEqual(fn.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"])
assert.doesNotMatch(fn, /import\s*\(/)
assert.match(fn, /data-fn-tile="shop"/)
assert.match(fn, /getElementById\('webshop-body'\)/)
})
test('Dateinamen: Webshop-Dateien beginnen nie mit termine (eigene Regeln der termine*.js-Tests)', async () => {
const { readdirSync } = await import('node:fs')
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^shop.*\.(js|css)$/.test(f))
assert.ok(dateien.includes('shop.js') && dateien.includes('shop.css'))
})
+46 -4
View File
@@ -30,11 +30,26 @@ test('ohne_dokument_ist_off', async () => {
assert.equal(list.find((p) => p.projectId === 'p1').liveUrl, 'https://baeckerei.de')
})
test('C1: eine neue Bestellung leert IP und Browser aus einer frueheren Bestellung desselben Dokuments', async () => {
const first = await sf.markOrdered({ customerId: 'c2', projectId: 'p3', key: 'news', sessionId: 'cs_c1_a', consent })
// Altbestand: eine frueher gespeicherte IP am Dokument (vor dieser Aenderung wurde sie geschrieben)
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
await updateDocument('portalFeatures', first.$id, { consentIp: '9.9.9.9', consentUserAgent: 'alt', state: 'off', enabled: false })
const again = await sf.markOrdered({ customerId: 'c2', projectId: 'p3', key: 'news', sessionId: 'cs_c1_b', consent })
assert.deepEqual([again.consentIp, again.consentUserAgent], ['', ''])
assert.equal(aw.docs('portalFeatures').get(first.$id).consentIp, '')
aw.docs('portalFeatures').delete(first.$id) // die uebrigen Faelle dieser Datei zaehlen Dokumente
})
test('bezahlt_legt_ticket_an', async () => {
const ordered = await sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_1', consent })
assert.equal(ordered.state, 'ordered')
assert.equal(ordered.enabled, false)
assert.equal(ordered.consentIp, '1.2.3.4')
// C1: IP und Browser der Bestellung stehen nur im Nachweis (featureConsents), nie am Funktionsdokument, auch wenn der Aufrufer sie mitgibt
assert.equal(ordered.consentIp, '')
assert.equal(ordered.consentUserAgent, '')
assert.equal(ordered.consentText, 'Ich stimme zu')
assert.equal(ordered.consentAt, '2026-09-30T10:00:00.000Z')
assert.match(ordered.siteKey, /^[a-z2-7]{16}$/)
const { doc, created } = await sf.markPaid({ sessionId: 'cs_1', invoiceId: 'in_1', invoiceUrl: 'https://x/inv' })
assert.equal(created, true)
@@ -170,10 +185,11 @@ test('termine_ist_zweite_funktion: Schluessel und Bezeichnungen', () => {
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' })
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremder Salon' })
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(sf.FEATURE_LABELS, {
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
shop: { name: 'Webshop', keine: 'keinen Webshop' },
zeiten: { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' },
})
})
@@ -181,7 +197,7 @@ test('termine_ist_zweite_funktion: Schluessel und Bezeichnungen', () => {
test('liste_zeigt_termine_je_projekt_ohne_dokument_als_off', async () => {
const list = await sf.listCustomerFeatures('c1')
const p = list.find((x) => x.projectId === 'pt1')
assert.deepEqual(Object.keys(p.features), ['news', 'termine', 'zeiten'])
assert.deepEqual(Object.keys(p.features), ['news', 'termine', 'shop', 'zeiten'])
assert.equal(p.features.termine.state, 'off')
assert.equal(p.features.termine.siteKey, null)
assert.equal(p.features.termine.ticketWoid, null)
@@ -247,7 +263,7 @@ test('activateClosedTickets: Funktionen mit ticketFreischaltung; News verhaelt s
const ids = ['f_setup_termine', 'f_setup_news', 'f_setup_analyse']
const reg = await import('../server/services/featureRegistry.js')
try {
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'zeiten'])
// News wie bisher; Termine wartet auf Einstellung und Abo (Haken freischaltbar); Analyse hat keine ticketFreischaltung
const done = await sf.activateClosedTickets({ customerId: 'c1' })
assert.deepEqual(done.map((d) => d.featureKey), ['news'])
@@ -721,3 +737,29 @@ test('featureView: nach disable() steht der Grund in der Ansicht und die Frist i
const danach = (await sf.listCustomerFeatures('c_e2')).find((p) => p.projectId === 'p_e2_live').features.termine
assert.deepEqual([danach.disabledReason, danach.disabledAt, danach.loeschungAm], [null, null, null], 'Wiedereinschalten leert die Anzeige')
})
// ------------------------------------------------ Shop: Kontext-Cache nach dem Abschalten (Review A, M10) ---
test('M10: disable und disableCustomerFeatures leeren den Kontext-Cache des Shops (kein Annehmen von Bestellungen bis zu 30 s nach dem Abschalten)', async () => {
const K = await import('../server/services/shop/kontext.js')
const feature = (id, siteKey, projectId) => ({ $id: id, customerId: 'c1', projectId, featureKey: 'shop', siteKey, state: 'active', enabled: true })
const shopDoc = (id, siteKey, projectId) => ({ $id: id, customerId: 'c1', projectId, siteKey, anfrageStatus: 'angenommen', geoeffnet: true, version: 1 })
aw.seed('portalFeatures', feature('f_m10_a', 'abcdefghijklmnop', 'p1'))
aw.seed('shopConfig', shopDoc('f_m10_a', 'abcdefghijklmnop', 'p1'))
aw.seed('portalFeatures', feature('f_m10_b', 'bcdefghijklmnopq', 'p2'))
aw.seed('shopConfig', shopDoc('f_m10_b', 'bcdefghijklmnopq', 'p2'))
K.leereCache()
assert.equal((await K.shopPerSiteKey('abcdefghijklmnop')).doc.state, 'active')
await sf.disable('f_m10_a', { by: 'kenso' })
assert.equal((await K.shopPerSiteKey('abcdefghijklmnop')).doc.state, 'disabled')
assert.equal((await K.shopPerSiteKey('bcdefghijklmnopq')).doc.state, 'active')
await sf.disableCustomerFeatures('c1', 'shop', { adminNote: 'Kunde gelöscht' })
assert.equal((await K.shopPerSiteKey('bcdefghijklmnopq')).doc.state, 'disabled')
for (const id of ['f_m10_a', 'f_m10_b']) {
aw.docs('portalFeatures').delete(id)
aw.docs('shopConfig').delete(id)
}
K.leereCache()
})
+21
View File
@@ -281,3 +281,24 @@ test('termine_einrichtung_einmalpreis', async () => {
assert.equal(await ensurePrice('termineSetup'), id)
assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0)
})
test('shop_einrichtung_einmalpreis', async () => {
const before = st.calls.length
const id = await ensurePrice('shopSetup')
const calls = st.calls.slice(before)
const created = calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices')
assert.equal(created.length, 1)
assert.equal(created[0].body['unit_amount'], '29900')
assert.equal(created[0].body['lookup_key'], 'webklar_shop_einrichtung_29900')
assert.equal(created[0].body['nickname'], 'Webshop-Einrichtung 299 €')
assert.ok(!Object.keys(created[0].body).some((k) => k.startsWith('recurring')), 'kein recurring')
assert.equal(st.prices.get(id).recurring, null)
const products = calls.filter((c) => c.method === 'POST' && c.path === '/v1/products')
assert.equal(products.length, 1)
assert.equal(products[0].body['name'], 'Webshop – Einrichtung')
assert.equal(products[0].body['metadata[webklar]'], 'shop-einrichtung')
// zweiter Aufruf: kein zweiter Preis (auch ohne Cache per lookup_key gefunden)
const again = st.calls.length
assert.equal(await ensurePrice('shopSetup'), id)
assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0)
})
+14 -2
View File
@@ -31,12 +31,13 @@ test('Import startet main() nicht: kein Stripe-Aufruf, kein process.exit', () =>
assert.equal(st.calls.length, 0)
})
test('KEYS enthaelt den Terminplaner- und den Oeffnungszeiten-Katalogschluessel', () => {
assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'zeitenSetup'])
test('KEYS enthaelt die Katalogschluessel der Einrichtungen (News, Terminplaner, Webshop, Oeffnungszeiten live)', () => {
assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup'])
})
test('catalogEntry liefert die Katalogeintraege aus der Konfiguration', () => {
assert.equal(script.catalogEntry('termineSetup'), config.termineSetup)
assert.equal(script.catalogEntry('shopSetup'), config.shopSetup)
assert.equal(script.catalogEntry('zeitenSetup'), config.zeitenSetup)
assert.equal(script.catalogEntry('newsSetup'), config.newsSetup)
assert.equal(script.catalogEntry('mailStorage'), config.mailStorage)
@@ -44,6 +45,17 @@ test('catalogEntry liefert die Katalogeintraege aus der Konfiguration', () => {
for (const key of script.KEYS) assert.ok(script.catalogEntry(key)?.lookupKey, `${key} hat einen lookupKey`)
})
test('config.shopSetup: Festwerte', () => {
assert.deepEqual(config.shopSetup, {
key: 'shopSetup',
name: 'Webshop – Einrichtung',
amountCents: 29900,
lookupKey: 'webklar_shop_einrichtung_29900',
oneTime: true,
priceNickname: 'Webshop-Einrichtung 299 €',
})
})
test('config.termineSetup: Festwerte', () => {
assert.deepEqual(config.termineSetup, {
key: 'termineSetup',
+249
View File
@@ -0,0 +1,249 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { createHmac } from 'node:crypto'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import express from 'express'
import { startFakeGitea } from './helpers/fakeGitea.js'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
// Sperre gegen fremde Subdomains: Ein kopiertes Repo (git clone + push, Vorlage) bringt die
// .webklar-preview.json des Originals mit. Ohne Sperre ueberschrieb sein erster Push die
// Preview des Originals (Olzinger -> HSK-Vorlage, 04.10.2026).
const WEBHOOK_TOKEN = 's3cret'
const ORIGINAL = 'WEBklar/Olzinger'
const KOPIE = 'WEBklar/HSK-Kopie'
const previewJson = (subdomain) =>
JSON.stringify({ enabled: true, type: 'static', branch: 'main', displayName: 'Olzinger', subdomain })
let gitea
let aw
let tmp
let originalCwd
let server
let baseUrl
let foreignSubdomainOwner
let runDeploy
let foreignSubdomainBlock
function pushPayload(repo, { sha, branch = 'main', pusher = 'knso' }) {
return {
ref: `refs/heads/${branch}`,
repository: { full_name: repo, name: repo.split('/')[1], html_url: `${gitea.baseUrl}/${repo}` },
commits: [{ id: sha, message: `Commit ${sha}`, author: { name: pusher } }],
pusher: { username: pusher },
after: sha,
}
}
async function webhook(payload) {
const bodyText = JSON.stringify(payload)
const signature = createHmac('sha256', WEBHOOK_TOKEN).update(bodyText).digest('hex')
const res = await fetch(`${baseUrl}/webhook/gitea`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Gitea-Signature': signature },
body: bodyText,
})
return { status: res.status, body: await res.json().catch(() => null) }
}
const docOf = (repo) => aw.all('websiteProjects').find((d) => d.repoFullName === repo)
before(async () => {
gitea = await startFakeGitea({
repos: [
{ full_name: ORIGINAL },
{ full_name: KOPIE },
{ full_name: 'WEBklar/neu-name' },
{ full_name: 'WEBklar/relaunch-kopie' },
{ full_name: 'WEBklar/eigene-seite' },
],
redirects: { 'knso/alt-name': 'WEBklar/neu-name' },
contents: {
[`${ORIGINAL}:.webklar-preview.json`]: previewJson('olzinger'),
[`${KOPIE}:.webklar-preview.json`]: previewJson('olzinger'),
'WEBklar/relaunch-kopie:.webklar-preview.json': previewJson('relaunch'),
// hat schon eine eigene Preview, Config von Hand auf eine fremde Subdomain gestellt
'WEBklar/eigene-seite:.webklar-preview.json': previewJson('olzinger'),
},
})
aw = await startFakeAppwrite({
unique: { websiteProjects: ['repoFullName', 'subdomain'] },
seed: {
websiteProjects: [
{ $id: 'olz', repoFullName: ORIGINAL, projectName: 'Olzinger', subdomain: 'olzinger', customerId: 'c1', ticketId: 't1', status: 'deployed' },
// per Gitea umgezogen (alter Name leitet weiter), Dokument noch unter dem alten Namen
{ $id: 'umzug', repoFullName: 'knso/alt-name', projectName: 'Umzug', subdomain: 'umzug-seite' },
// Repo in Gitea geloescht, Dokument haelt die Subdomain noch
{ $id: 'weg', repoFullName: 'knso/geloescht', projectName: 'Weg', subdomain: 'weg-seite' },
// gehostet ohne Repo (wie die Relaunch-Vorschau)
{ $id: 'rel', repoFullName: '', projectName: 'WEBklar Relaunch', subdomain: 'relaunch' },
{ $id: 'eigen', repoFullName: 'WEBklar/eigene-seite', projectName: 'Eigene Seite', subdomain: 'eigene-seite', status: 'deployed', provisioningStatus: 'ready' },
],
workorders: [{ $id: 't1', woid: '100130', customerName: 'Olzinger', topic: 'Website', status: 'Open' }],
customers: [{ $id: 'c1', name: 'Olzinger GmbH' }],
employees: [{ $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com' }],
},
})
originalCwd = process.cwd()
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'subdomain-sperre-'))
process.chdir(tmp)
Object.assign(process.env, {
GITEA_BASE_URL: gitea.baseUrl,
GITEA_API_TOKEN: 'test-token',
GITEA_WEBHOOK_TOKEN: WEBHOOK_TOKEN,
APPWRITE_ENDPOINT: aw.endpoint,
APPWRITE_API_KEY: 'test-key',
PREVIEW_DEPLOY_ROOT: '',
TRAEFIK_DYNAMIC_DIR: '',
PUSH_TIME_SECRET: 'w'.repeat(40),
})
const [{ default: giteaWebhookRoutes }, previewDeploy, mailer] = await Promise.all([
import('../server/routes/webhook/gitea.js'),
import('../server/services/previewDeploy.js'),
import('../server/services/mailer.js'),
])
foreignSubdomainOwner = previewDeploy.foreignSubdomainOwner
runDeploy = previewDeploy.runDeploy
foreignSubdomainBlock = previewDeploy.foreignSubdomainBlock
mailer.setMailTransportForTests({ sendMail: async () => ({}) })
const app = express()
app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } }))
app.use('/webhook', giteaWebhookRoutes)
server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
baseUrl = `http://127.0.0.1:${server.address().port}`
})
after(async () => {
if (server) await new Promise((resolve) => server.close(resolve))
if (originalCwd) process.chdir(originalCwd)
await gitea?.close()
await aw?.close()
if (tmp) await fs.rm(tmp, { recursive: true, force: true })
})
test('foreignSubdomainOwner: freie Subdomain und eigenes Repo sind nicht fremd', async () => {
assert.equal(await foreignSubdomainOwner(KOPIE, 'noch-frei'), '')
assert.equal(await foreignSubdomainOwner(ORIGINAL, 'olzinger'), '')
assert.equal(await foreignSubdomainOwner('webklar/OLZINGER', 'olzinger'), '', 'Schreibweise egal')
assert.equal(await foreignSubdomainOwner(KOPIE, ''), '')
})
test('foreignSubdomainOwner: Subdomain eines anderen Repos ist fremd', async () => {
assert.equal(await foreignSubdomainOwner(KOPIE, 'olzinger'), ORIGINAL)
})
test('foreignSubdomainOwner: in Gitea umgezogenes Repo ist nicht fremd, geloeschtes schon', async () => {
assert.equal(await foreignSubdomainOwner('WEBklar/neu-name', 'umzug-seite'), '')
assert.equal(await foreignSubdomainOwner(KOPIE, 'weg-seite'), 'knso/geloescht')
})
test('foreignSubdomainOwner: Projekt ohne Repo haelt seine Subdomain, sperrt sich aber nicht selbst', async () => {
assert.equal(await foreignSubdomainOwner(KOPIE, 'relaunch'), 'Projekt „WEBklar Relaunch“ (ohne Repo)')
assert.equal(await foreignSubdomainOwner('', 'relaunch'), '')
})
test('runDeploy verweigert eine fremde Subdomain, bevor irgendetwas geklont wird', async () => {
const before = gitea.calls.length
const r = await runDeploy(KOPIE, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' })
assert.equal(r.deployed, false)
assert.equal(r.blocked, true)
assert.equal(r.reason, 'subdomain_foreign')
assert.equal(r.subdomain, 'olzinger')
assert.equal(r.owner, ORIGINAL)
// nur die Besitzer-Pruefung fragt Gitea (nach dem Original), fuer die Kopie wird nichts geholt
assert.deepEqual(gitea.calls.slice(before).map((c) => `${c.method} ${c.path}`), [`GET /api/v1/repos/${ORIGINAL}`])
})
test('runDeploy: Pruefung nicht moeglich (Appwrite-Fehler) → Deploy laeuft trotzdem', async () => {
aw.failNext({ method: 'GET', pathIncludes: 'websiteProjects', status: 500 })
const r = await runDeploy(KOPIE, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' })
assert.equal(r.blocked, undefined)
assert.equal(r.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt')
})
test('foreignSubdomainBlock ohne failOpen (Webhook): Appwrite-Fehler wird weitergereicht', async () => {
aw.failNext({ method: 'GET', pathIncludes: 'websiteProjects', status: 500 })
await assert.rejects(foreignSubdomainBlock(KOPIE, 'olzinger'))
assert.equal((await foreignSubdomainBlock(KOPIE, 'olzinger')).owner, ORIGINAL)
})
test('runDeploy: eigene Subdomain des uebergebenen Projekts und subdomainChecked brauchen keine Abfrage', async () => {
const calls = aw.calls.length
const own = await runDeploy(ORIGINAL, 'main', { subdomain: 'olzinger' }, { project: aw.get('websiteProjects', 'olz') })
assert.equal(own.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt')
const checked = await runDeploy(KOPIE, 'main', { subdomain: 'olzinger' }, { subdomainChecked: true })
assert.equal(checked.blocked, undefined, 'der Aufrufer hat schon geprueft')
assert.equal(aw.calls.length, calls)
})
test('runDeploy: eigene Subdomain laeuft weiter wie bisher', async () => {
const r = await runDeploy(ORIGINAL, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' })
assert.equal(r.blocked, undefined)
assert.equal(r.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt')
})
test('Webhook: Push einer Kopie mit fremder Subdomain → kein Deploy, Projekt ohne Preview', async () => {
const r = await webhook(pushPayload(KOPIE, { sha: 'aaaa1111' }))
assert.equal(r.status, 200)
assert.equal(r.body.subdomain, '')
assert.equal(r.body.previewUrl, '')
assert.equal(r.body.deploy.blocked, true)
assert.equal(r.body.deploy.owner, ORIGINAL)
const kopie = docOf(KOPIE)
assert.ok(kopie, 'Dokument der Kopie angelegt')
assert.ok(!kopie.subdomain, 'Kopie bekommt die fremde Subdomain nicht')
assert.equal(kopie.status, 'synced')
assert.equal(kopie.projectName, 'HSK-Kopie', 'nicht der Anzeigename aus der fremden Config')
const original = aw.get('websiteProjects', 'olz')
assert.equal(original.subdomain, 'olzinger')
assert.equal(original.status, 'deployed')
})
test('Webhook: Kopie auf anderem Branch bekommt die fremde Subdomain auch nicht', async () => {
const r = await webhook(pushPayload(KOPIE, { sha: 'bbbb2222', branch: 'entwurf' }))
assert.equal(r.status, 200)
assert.equal(r.body.subdomain, '')
assert.ok(!docOf(KOPIE).subdomain)
})
test('Webhook: Kopie mit der Subdomain eines Projekts ohne Repo wird gesperrt', async () => {
const r = await webhook(pushPayload('WEBklar/relaunch-kopie', { sha: 'cccc3333' }))
assert.equal(r.status, 200)
assert.equal(r.body.deploy.blocked, true)
assert.equal(aw.get('websiteProjects', 'rel').subdomain, 'relaunch')
})
test('Webhook: Push des Originals deployt wie bisher', async () => {
const r = await webhook(pushPayload(ORIGINAL, { sha: 'dddd4444' }))
assert.equal(r.status, 200)
assert.equal(r.body.subdomain, 'olzinger')
assert.equal(r.body.deploy.blocked, undefined)
assert.equal(r.body.deploy.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt')
assert.equal(docOf(ORIGINAL).subdomain, 'olzinger')
})
test('Webhook: Projekt mit eigener Preview und fremder Subdomain in der Config bleibt, wie es ist', async () => {
const r = await webhook(pushPayload('WEBklar/eigene-seite', { sha: 'ffff6666' }))
assert.equal(r.status, 200)
assert.equal(r.body.deploy.blocked, true)
const doc = aw.get('websiteProjects', 'eigen')
assert.equal(doc.subdomain, 'eigene-seite')
assert.equal(doc.status, 'deployed')
assert.equal(doc.provisioningStatus, 'ready')
assert.equal(doc.projectName, 'Eigene Seite')
assert.equal(doc.previewState, 'failed', 'WOMS zeigt den Fehlertext nur bei failed')
assert.match(doc.previewError, /„olzinger“, die gehört WEBklar\/Olzinger/)
assert.equal(aw.get('websiteProjects', 'olz').subdomain, 'olzinger')
})
+10 -2
View File
@@ -11,8 +11,10 @@ import http from 'node:http'
*
* Bulk-Create (POST .../documents mit { documents: [{ $id, ...felder }] }, 1 bis 100 Dokumente) ist alles oder
* nichts: vorhandene oder doppelte $id => 409, ungueltige $id, unbekanntes Attribut oder ungueltiges Datum => 400,
* dann ist nichts geschrieben. Abfragen: equal, notEqual (mit Liste: keiner von; ein Dokument ohne das Feld passt),
* greaterThan(Equal), lessThan(Equal), between (inklusiv), orderAsc/orderDesc, limit, cursorAfter, select (nur die
* dann ist nichts geschrieben. Abfragen: equal (mit Liste: einer von), notEqual (wie Appwrite 1.8.1, gemessen 2026-10-05:
* genau EIN Wert, sonst HTTP 400 "Invalid query: NotEqual queries require exactly one value." general_query_invalid; mehrere
* notEqual in einer Anfrage werden geschnitten; ein Dokument, dessen Feld null ist oder fehlt, passt NICHT zu notEqual, SQL-
* Semantik), greaterThan(Equal), lessThan(Equal), between (inklusiv), orderAsc/orderDesc, limit, cursorAfter, select (nur die
* genannten Felder plus alle $-Felder). Die Antwort traegt total = Treffer der Filter, unabhaengig von limit und
* cursorAfter. listCalls(collection?) / resetListCalls() zeichnen die Listenabfragen auf. Zwei Werte, die beide
* wie "YYYY-MM-DDT..." aussehen, vergleicht er als Zeitpunkte (Schreibweise Z, +00:00, +02:00 ist egal), alle
@@ -133,6 +135,8 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
case 'equal':
return query.values.some((candidate) => same(value, candidate))
case 'notEqual':
// SQL: NULL <> 'x' ist nicht wahr, ein Dokument ohne Wert faellt heraus (Appwrite 1.8.1)
if (value === undefined || value === null) return false
return !query.values.some((candidate) => same(value, candidate))
case 'greaterThan':
return greater(value, first)
@@ -158,6 +162,10 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
const queries = rawQueries.map((q) => JSON.parse(q))
const unsupported = queries.find((q) => !supportedQueries.includes(q.method))
if (unsupported) return [400, { message: `Query ${unsupported.method} wird nicht nachgebaut`, code: 400 }]
// Appwrite 1.8.1: notEqual nimmt genau einen Wert (der Nachbau lehnt die Liste wie die echte Datenbank ab)
if (queries.some((q) => q.method === 'notEqual' && (!Array.isArray(q.values) || q.values.length !== 1))) {
return [400, { message: 'Invalid query: NotEqual queries require exactly one value.', code: 400, type: 'general_query_invalid' }]
}
// cursorAfter: das Dokument mit dieser ID muss es geben (es muss aber nicht zu den Filtern passen)
const cursorId = queries.find((q) => q.method === 'cursorAfter')?.values[0]
if (cursorId !== undefined && !docs(collection).has(cursorId)) {
+22 -3
View File
@@ -3,7 +3,8 @@
// const fx = installFakeDom({ antworten: [{ status: 400, body: { fehler: {...} } }] })
// try { ...Client-Code bauen, fx.klick(knopf), fx.fetchLog pruefen... } finally { fx.restore() }
//
// installFakeDom stellt `document`, `window` (nur location mit href, pathname, search, hash und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
// installFakeDom stellt `document`, `window` (location mit href, pathname, search, hash und reload() - jeder Aufruf steht in fx.reloads -, history.replaceState und
// addEventListener/removeEventListener/dispatchEvent fuer Ereignisse des Fensters wie 'pageshow'), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
// alten Zustand wieder her. `h` aus public/termine-dom.js liest `document` bei jedem Aufruf: der Client braucht keine Einhaengepunkte.
// Wurzeln, die `isConnected` brauchen (neuerSpeicher.nochHier), haengt der Test selbst an `document.body`.
//
@@ -471,7 +472,7 @@ const GLOBALE = ['document', 'window', 'CSS', 'fetch']
/**
* Fake-DOM und Fake-fetch auf globalThis stellen.
* => { document, window, fetchLog, antworten, finde, alle, text, klick, setze, sende, ruhe, restore }
* => { document, window, fetchLog, reloads, antworten, finde, alle, text, klick, setze, sende, ruhe, restore }
* finde(baum, vergleich) / alle(baum, vergleich): erstes / alle Elemente im Baum (baum selbst eingeschlossen, Dokumentreihenfolge), fuer die
* vergleich(el) wahr ist (Funktion) oder die zum Selektor passen (String).
* text(baum): Text des Baums (textContent). klick(el), setze(el, wert), ruhe(): siehe Kopf der Datei.
@@ -479,11 +480,28 @@ const GLOBALE = ['document', 'window', 'CSS', 'fetch']
export function installFakeDom({ antworten = [] } = {}) {
const vorher = Object.fromEntries(GLOBALE.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]))
const document = neuesDokument()
const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '' }
const reloads = []
const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '', reload: () => { reloads.push(location.href) } }
const fensterEreignisse = new Map()
const window = {
document,
location,
history: { replaceState: (_zustand, _titel, url) => { location.hash = String(url ?? '').includes('#') ? `#${String(url).split('#')[1]}` : '' } },
addEventListener(typ, fn) {
if (!fensterEreignisse.has(typ)) fensterEreignisse.set(typ, [])
fensterEreignisse.get(typ).push(fn)
},
removeEventListener(typ, fn) {
const liste = fensterEreignisse.get(typ)
if (liste?.includes(fn)) liste.splice(liste.indexOf(fn), 1)
},
/** Ereignis an die Handler des Fensters (z. B. { type: 'pageshow', persisted: true }); Rueckgaben werden nicht abgewartet. */
dispatchEvent(ev) {
for (const fn of [...(fensterEreignisse.get(ev.type) ?? [])]) fn(ev)
return true
},
/** Zahl der Handler eines Typs (fuer Tests, die Aufraeumen pruefen). */
anzahlHandler: (typ) => (fensterEreignisse.get(typ) ?? []).length,
}
const fetchLog = []
const warteschlange = [...antworten]
@@ -503,6 +521,7 @@ export function installFakeDom({ antworten = [] } = {}) {
document,
window,
fetchLog,
reloads,
antworten: warteschlange,
finde: (baum, vergleich) => knoten(baum).find(pruefung(vergleich)) ?? null,
alle: (baum, vergleich) => knoten(baum).filter(pruefung(vergleich)),
+183 -3
View File
@@ -32,6 +32,16 @@ import http from 'node:http'
* Kunden: GET /v1/customers/search (Metadaten), POST /v1/customers (anlegen) und POST /v1/customers/:id (aendern:
* email, name, address, metadata; unbekannte ID -> 404). Wie Stripe fuehrt ein Update die Anschrift feldweise zusammen
* (nur gesendete Felder aendern sich) und ein leerer Text loescht das Feld (gespeichert als null).
*
* Connect (Webshop D1): die Kopfzeile Stripe-Account steht als account in calls (nur bei Aufrufen mit Konto). POST /v1/accounts
* (v1 mit controller; Konto startet unfertig: charges_enabled false, requirements.currently_due ['external_account'];
* gleicher Idempotency-Key liefert dasselbe Konto), GET /v1/accounts/:id (unbekannt -> 404), POST /v1/account_links
* (Konto muss existieren). st.setAccount(id, patch) legt ein Konto an oder aendert es (requirements wird feldweise gemischt).
* Aufrufe mit Stripe-Account arbeiten auf der Ablage dieses Kontos (st.accountSessions(id), st.refunds(id)); ohne Kopfzeile
* bleibt alles wie bisher in st.checkoutSessions. Dort: POST /v1/checkout/sessions, GET .../:id, POST .../:id/expire (nur open),
* POST /v1/refunds (amount fehlt = Rest der Zahlung; Zahlungen legt st.setPaymentIntent(acct, id, { amount }) an; Summe der
* Erstattungen darf die Zahlung nicht uebersteigen) und GET /v1/refunds/:id. Idempotency-Key wiederholt die erste Antwort
* (je Konto und Schluessel) fuer Sitzungen und Erstattungen.
*/
// Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/
@@ -54,6 +64,16 @@ export async function startFakeStripe() {
// Rechnungsposten ohne Rechnung (pending invoice items), je ID
const invoiceItems = new Map()
const checkoutSessions = new Map()
// Connect: Konten, deren Sitzungen, Zahlungen und Erstattungen (je Konto-ID), Antworten je Idempotency-Key
const accounts = new Map()
const accountSessionMaps = new Map()
const paymentIntentMaps = new Map()
const refundMaps = new Map()
const idempotentReplies = new Map()
const bucket = (maps, accountId) => {
if (!maps.has(accountId)) maps.set(accountId, new Map())
return maps.get(accountId)
}
// Abo-Aenderungen, die Stripe bis zur Zahlung zurueckhaelt (pending_update), je Abo-ID
const pendingUpdates = new Map()
const calls = []
@@ -166,8 +186,117 @@ export async function startFakeStripe() {
return merged
}
const route = (method, path, query, body) => {
const route = (method, path, query, body, account = null) => {
let m
// ---- Connect ----
if (method === 'POST' && path === '/v1/accounts') {
const data = unflatten(body)
const id = `acct_test${(seq += 1)}`
const obj = {
id,
object: 'account',
type: 'standard',
country: data.country || 'DE',
email: data.email ?? null,
business_profile: data.business_profile || {},
controller: { type: 'application', is_controller: false, ...(data.controller || {}) },
capabilities: Object.fromEntries(Object.keys(data.capabilities || {}).map((k) => [k, 'inactive'])),
metadata: data.metadata || {},
charges_enabled: false,
payouts_enabled: false,
details_submitted: false,
requirements: { currently_due: ['external_account'], eventually_due: ['external_account'], past_due: [], errors: [], disabled_reason: null },
}
accounts.set(id, obj)
return [200, obj]
}
if (method === 'GET' && (m = path.match(/^\/v1\/accounts\/([^/]+)$/))) {
return accounts.has(m[1]) ? [200, accounts.get(m[1])] : noSuch('account', m[1])
}
if (method === 'POST' && path === '/v1/account_links') {
const data = unflatten(body)
if (!accounts.has(data.account)) return noSuch('account', data.account)
if (!data.type) return invalid('type', 'Missing required param: type.')
return [200, {
object: 'account_link',
created: nowUnix(),
expires_at: nowUnix() + 300,
url: `https://connect.stripe.test/setup/${data.account}/${(seq += 1)}`,
}]
}
if (account) {
const sessions = bucket(accountSessionMaps, account)
if (method === 'POST' && path === '/v1/checkout/sessions') {
const data = unflatten(body)
const id = nextId('cs')
const obj = {
id,
object: 'checkout.session',
url: `https://checkout.stripe.test/c/pay/${id}`,
mode: data.mode || 'payment',
client_reference_id: data.client_reference_id ?? null,
success_url: data.success_url ?? null,
cancel_url: data.cancel_url ?? null,
expires_at: data.expires_at ? Number(data.expires_at) : nowUnix() + 86400,
metadata: data.metadata || {},
status: 'open',
payment_status: 'unpaid',
payment_intent: null,
amount_total: null,
currency: 'eur',
customer_details: null,
}
sessions.set(id, obj)
return [200, obj]
}
if (method === 'GET' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)$/))) {
return sessions.has(m[1]) ? [200, sessions.get(m[1])] : noSuch('checkout.session', m[1])
}
if (method === 'POST' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)\/expire$/))) {
const found = sessions.get(m[1])
if (!found) return noSuch('checkout.session', m[1])
if (found.status !== 'open') return invalid(undefined, 'Only Checkout Sessions with a status in open can be expired.')
const expired = { ...found, status: 'expired' }
sessions.set(found.id, expired)
return [200, expired]
}
if (method === 'POST' && path === '/v1/refunds') {
const data = unflatten(body)
const intents = bucket(paymentIntentMaps, account)
const refunds = bucket(refundMaps, account)
const intent = intents.get(data.payment_intent)
if (!data.payment_intent) return invalid('payment_intent', 'Either payment_intent or charge must be provided.')
if (!intent && !data.amount) return noSuch('payment_intent', data.payment_intent)
const already = [...refunds.values()].filter((r) => r.payment_intent === data.payment_intent).reduce((sum, r) => sum + r.amount, 0)
const rest = intent ? intent.amount - already : null
const amount = data.amount ? Number(data.amount) : rest
if (!Number.isInteger(amount) || amount <= 0) return invalid('amount', 'Invalid positive integer')
if (rest !== null && amount > rest) return invalid('amount', `Refund amount (${amount}) is greater than unrefunded amount on payment (${rest}).`)
const obj = {
id: nextId('re'),
object: 'refund',
amount,
currency: 'eur',
payment_intent: data.payment_intent,
reason: data.reason ?? null,
metadata: data.metadata || {},
status: 'succeeded',
created: nowUnix(),
}
refunds.set(obj.id, obj)
return [200, obj]
}
if (method === 'GET' && path === '/v1/refunds') {
const pi = query.get('payment_intent')
const items = [...bucket(refundMaps, account).values()].filter((r) => !pi || r.payment_intent === pi)
return [200, { object: 'list', data: items, has_more: false, url: '/v1/refunds' }]
}
if (method === 'GET' && (m = path.match(/^\/v1\/refunds\/([^/]+)$/))) {
const found = bucket(refundMaps, account).get(m[1])
return found ? [200, found] : noSuch('refund', m[1])
}
}
// ---- Plattform ----
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1])
}
@@ -626,7 +755,9 @@ export async function startFakeStripe() {
const body = await readBody(req)
// idempotencyKey (Kopfzeile Idempotency-Key) steht nur an Aufrufen, die einen mitschicken
const idempotencyKey = req.headers['idempotency-key']
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}) })
// Connect: Kopfzeile Stripe-Account (Aufruf im Namen eines Haendlerkontos)
const account = req.headers['stripe-account']
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}), ...(account ? { account } : {}) })
const delay = plannedDelay(req.method, url.pathname)
if (delay) await new Promise((resolve) => setTimeout(resolve, delay))
const failure = plannedFailure(req.method, url.pathname)
@@ -640,7 +771,10 @@ export async function startFakeStripe() {
res.writeHead(200, { 'Content-Type': 'application/pdf' })
return res.end(`%PDF-1.4\n% Stripe-Nachbau ${pdfMatch[1]}\n%%EOF\n`)
}
const [status, payload] = failure || route(req.method, url.pathname, url.searchParams, body)
// Wiederholung mit gleichem Idempotency-Key (nur Connect-Aufrufe und Kontoanlage): erste Antwort, kein neuer Zustand
const replyKey = !failure && req.method === 'POST' && idempotencyKey && (account || url.pathname === '/v1/accounts') ? `${account || ''}|${idempotencyKey}` : null
const [status, payload] = failure || (replyKey && idempotentReplies.get(replyKey)) || route(req.method, url.pathname, url.searchParams, body, account || null)
if (replyKey && status === 200 && !idempotentReplies.has(replyKey)) idempotentReplies.set(replyKey, [status, payload])
res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
res.end(JSON.stringify(payload))
})
@@ -659,6 +793,52 @@ export async function startFakeStripe() {
payouts,
invoiceItems,
checkoutSessions,
accounts,
/** Sitzungen eines Haendlerkontos (Map id -> Sitzung); Tests setzen status/payment_status/payment_intent direkt darin. */
accountSessions: (accountId) => bucket(accountSessionMaps, accountId),
/** Erstattungen eines Haendlerkontos (Map id -> Erstattung). */
refunds: (accountId) => bucket(refundMaps, accountId),
/** Konto anlegen oder aendern (z. B. { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }). */
setAccount(id, patch = {}) {
const base = accounts.get(id) || {
id, object: 'account', type: 'standard', country: 'DE', email: null, business_profile: {}, metadata: {},
controller: { type: 'application', is_controller: false }, capabilities: {},
charges_enabled: false, payouts_enabled: false, details_submitted: false,
requirements: { currently_due: ['external_account'], eventually_due: [], past_due: [], errors: [], disabled_reason: null },
}
const next = { ...base, ...patch, requirements: { ...base.requirements, ...(patch.requirements || {}) } }
accounts.set(id, next)
return next
},
/**
* Sitzung eines Haendlerkontos als abgeschlossen markieren (status complete) und die Felder setzen, die Stripe nach der Kasse
* liefert: completeSession('acct_x', 'cs_x', { payment_status: 'paid', amount_total, customer_details, shipping, payment_intent,
* payment_method_types }). shipping steht wie bei Stripe unter collected_information.shipping_details und shipping_details.
* Gibt die neue Sitzung zurueck (so schickt der Webhook sie).
*/
completeSession(accountId, id, { payment_status = 'paid', amount_total = null, customer_details = null, shipping = null, payment_intent = null, payment_method_types } = {}) {
const sessions = bucket(accountSessionMaps, accountId)
const found = sessions.get(id)
if (!found) throw new Error(`Stripe-Nachbau: keine Sitzung ${id} bei ${accountId}`)
const next = {
...found,
status: 'complete',
payment_status,
amount_total,
customer_details,
payment_intent,
...(payment_method_types ? { payment_method_types } : {}),
...(shipping ? { collected_information: { shipping_details: shipping }, shipping_details: shipping } : {}),
}
sessions.set(id, next)
return next
},
/** Zahlung eines Haendlerkontos (Betrag in Cent) fuer Erstattungen: setPaymentIntent('acct_x', 'pi_x', { amount: 2500 }). */
setPaymentIntent(accountId, id, { amount, currency = 'eur' }) {
const obj = { id, object: 'payment_intent', amount, currency }
bucket(paymentIntentMaps, accountId).set(id, obj)
return obj
},
/**
* Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit
* pending_update bleibt das Abo wie bei Stripe unveraendert (und traegt pending_update), bis
+3
View File
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import { TERMINE_COLLECTIONS } from '../../server/services/termine/schema.js'
import { SHOP_COLLECTIONS } from '../../server/services/shop/schema.js'
import { ZEITEN_COLLECTIONS, ZEITEN_FEATURE_ATTRIBUTES } from '../../server/services/zeiten/schema.js'
import { FEATURE_CONSENT_ATTRIBUTES, PORTAL_FEATURE_ATTRIBUTES } from '../../scripts/feature-setup.mjs'
@@ -11,6 +12,7 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
* scripts/setup-finanzen.mjs in Appwrite anlegen. Tests geben sie dem Appwrite-Nachbau als Schema:
* Schreibt der Code ein Feld, das das Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in
* Produktion. Die Termine-Collections kommen aus server/services/termine/schema.js (scripts/termine-setup.mjs),
* die Shop-Collections aus server/services/shop/schema.js (scripts/shop-setup.mjs),
* die Zeiten-Collections aus server/services/zeiten/schema.js (scripts/zeiten-setup.mjs);
* portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES) und die
* Zeiten-Felder aus server/services/zeiten/schema.js (ZEITEN_FEATURE_ATTRIBUTES, angelegt von scripts/zeiten-setup.mjs);
@@ -18,6 +20,7 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
*/
export function schemaKeys(collection) {
if (Object.hasOwn(TERMINE_COLLECTIONS, collection)) return TERMINE_COLLECTIONS[collection].attributes.map((a) => a.key)
if (Object.hasOwn(SHOP_COLLECTIONS, collection)) return SHOP_COLLECTIONS[collection].attributes.map((a) => a.key)
if (Object.hasOwn(ZEITEN_COLLECTIONS, collection)) return ZEITEN_COLLECTIONS[collection].attributes.map((a) => a.key)
if (collection === 'featureConsents') return FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key)
for (const script of SETUP_SCRIPTS) {
+46
View File
@@ -602,6 +602,52 @@ test('meldeAboEnde: unbekannter Projektname faellt auf "Dein Projekt" zurueck, e
assert.equal(mails().length, 1)
})
// ---------------------------------------------------------------- C3: das Datum in K22 ist das Abo-Ende, nicht der Tag des Abschaltens
const AM_ABO_ENDE = new Date('2026-10-02T09:30:00.000Z') // drei Tage vor dem Lauf (JETZT = 05.10.2026)
test('C3: K22 nennt das Abo-Ende (endedAt des Abos), wenn es frueher war als das Abschalten; das Loeschdatum bleibt disabledAt + 30 Tage', async () => {
legeFunktion(1)
wartung('canceled', { endedAt: AM_ABO_ENDE.toISOString(), canceledAt: '2026-09-20T08:00:00.000Z' })
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(ms(funktion().disabledAt), JETZT.getTime())
const mail = mails()[0]
assert.match(mail.text, /weil Dein Wartungs-Abo am 02\.10\.2026 beendet wurde/, 'Enddatum = Abo-Ende, nicht der 05.10.')
assert.match(mail.text, /Beendet am: 02\.10\.2026/, 'auch die Zeile "Beendet am"')
assert.match(mail.text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum = Abschalten (05.10.) + 30 Tage')
})
test('C3: ohne endedAt gilt canceledAt, ohne beides der Tag des Abschaltens (disabledAt)', async () => {
legeFunktion(1)
wartung('canceled', { endedAt: null, canceledAt: AM_ABO_ENDE.toISOString() })
await laufe(JETZT)
assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 02\.10\.2026 beendet wurde/, 'canceledAt als Abo-Ende')
const id = legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }, { projekt: 'Zweites' })
assert.equal(await meldeAboEnde(funktion(id), { lage: { laeuft: false, art: 'beendet', endedAt: null }, now: JETZT }), true)
assert.match(mails().at(-1).text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'ohne Abo-Ende: disabledAt')
const id3 = legeFunktion(3, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }, { projekt: 'Drittes' })
assert.equal(await meldeAboEnde(funktion(id3), { lage: { laeuft: false, art: 'beendet', endedAt: 'kein Datum' }, now: JETZT }), true)
assert.match(mails().at(-1).text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'unlesbares endedAt: disabledAt')
})
test('C3: bei einem Hosting-Abo nennt K22 den Tag des Abschaltens, nicht das Kuendigungsdatum des Hosting-Abos', async () => {
const id = legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() })
assert.equal(await meldeAboEnde(funktion(id), { lage: { laeuft: false, art: 'hosting', endedAt: AM_ABO_ENDE.toISOString() }, now: JETZT }), true)
const text = mails().at(-1).text
assert.match(text, /05\.10\.2026/, 'Tag des Abschaltens')
assert.doesNotMatch(text, /02\.10\.2026/, 'nicht das Datum des Hosting-Abos')
})
test('C3: K22 vor_einrichtung nennt ebenfalls das Abo-Ende', async () => {
legeAufbau(1)
wartung('canceled', { endedAt: AM_ABO_ENDE.toISOString() })
await laufe(JETZT)
assert.equal(funktion().disabledReason, 'abo_ende_vor_einrichtung')
assert.match(k22()[0].text, /Dein Wartungs-Abo wurde am 02\.10\.2026 beendet, noch vor der Einrichtung/)
})
// ---------------------------------------------------------------- Schritt 1b: Abo endet vor der Einrichtung (E5)
test('Bezahlt im Aufbau (setup, Ticket) ohne Abo: abgeschaltet, ein T19 an das Team, K22 vor_einrichtung, Ticket bleibt offen, am Folgetag nichts', async () => {
+78 -7
View File
@@ -329,6 +329,7 @@ test('abo gibt es nur in off; offen und bestellung nur in off und ordered (R110)
setzeZustand(zustand)
const { body } = await send('GET', einstellungenUrl(), { cookie })
assert.equal(body.abo, null, zustand)
assert.equal(body.aboHinweis, null, `${zustand}: den Abo-Hinweis gibt es nur in off`)
assert.equal(body.offen, null, zustand)
assert.equal(body.bestellung, null, zustand)
}
@@ -340,6 +341,7 @@ test('abo gibt es nur in off; offen und bestellung nur in off und ordered (R110)
assert.notEqual(body.offen, null, zustand)
assert.notEqual(body.bestellung, null, zustand)
assert.equal(body.abo === null, zustand === 'ordered', `${zustand}: das Abo liest die Route nur in off`)
assert.equal(body.aboHinweis, null, `${zustand}: mit laufendem Abo (off) und in ordered kein Hinweis`)
}
})
@@ -406,6 +408,7 @@ test('off mit bestellbarem Entwurf und laufendem Wartungs-Abo: abo { ok: true },
setzeZustand('off')
const { body } = await send('GET', einstellungenUrl(), { cookie })
assert.deepEqual(body.abo, { ok: true })
assert.equal(body.aboHinweis, null, 'laeuft das Abo, gibt es keinen Hinweis')
assert.deepEqual(body.offen, [])
assert.deepEqual(body.bestellung, JSON.parse(JSON.stringify(await bestellungAnsicht(T))))
assert.equal(body.bestellung.consent.knopf, T.consent.knopf, 'der Knopf zeigt genau den Registertext')
@@ -454,6 +457,7 @@ test('Abo ohne laufendes Wartungs-Abo (nur Hosting, oder gar keins): abo { ok: f
const { body } = await send('GET', einstellungenUrl(), { cookie })
assert.equal(body.state, 'off', name)
assert.deepEqual(body.abo, { ok: false, grund: 'kein_abo' }, name)
assert.equal(body.aboHinweis, text, `${name}: B2, der Hinweis ist derselbe Text aus aboMeldung`)
assert.deepEqual(body.offen, [{ schritt: 'bestellen', text }], name)
}
})
@@ -463,12 +467,14 @@ test('Abo mit vorgemerkter Kündigung: grund gekuendigt; past_due: grund nicht_a
wartung({ cancelAtPeriodEnd: true })
let body = (await send('GET', einstellungenUrl(), { cookie })).body
assert.deepEqual(body.abo, { ok: false, grund: 'gekuendigt' })
assert.equal(body.aboHinweis, aboMeldung(T, 'gekuendigt'))
assert.deepEqual(body.offen, [{ schritt: 'bestellen', text: aboMeldung(T, 'gekuendigt') }])
assert.equal(body.offen[0].text, 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.')
wartung({ status: 'past_due' })
body = (await send('GET', einstellungenUrl(), { cookie })).body
assert.deepEqual(body.abo, { ok: false, grund: 'nicht_aktiv' })
assert.equal(body.aboHinweis, aboMeldung(T, 'nicht_aktiv'))
assert.deepEqual(body.offen, [{ schritt: 'bestellen', text: aboMeldung(T, 'nicht_aktiv') }])
})
@@ -478,6 +484,8 @@ test('Lesefehler beim Abo: GET bleibt 200, abo null und der offene Punkt "nicht
const res = await send('GET', einstellungenUrl(), { cookie })
assert.equal(res.status, 200)
assert.equal(res.body.abo, null)
assert.equal(res.body.aboHinweis, aboMeldung(T, 'unbekannt'), 'B2: auch ohne lesbares Abo bekommt der Kunde einen Text, nie eine leere Karte')
assert.equal(res.body.aboHinweis, 'Das Abo konnte gerade nicht geprüft werden.')
assert.deepEqual(res.body.offen, [{ schritt: 'bestellen', text: aboMeldung(T, 'unbekannt') }])
assert.equal(res.body.offen[0].text, 'Das Abo konnte gerade nicht geprüft werden.')
assert.ok(!JSON.stringify(res.body).includes('geheimer'))
@@ -1374,8 +1382,17 @@ const beispielFragebogen = (over = {}) => ({
...over,
})
/**
* Bestellbarer Stand, aber noch kein Schritt bestätigt und noch keine Branche gewählt (B1): PUT vereinigt bestaetigt mit dem gespeicherten Stand und
* lässt einen Wechsel der gespeicherten Branche nicht zu. Die Tests, die einen beliebigen Fragebogen per PUT ablegen, gehen von diesem Stand aus.
*/
function standOhneBestaetigung({ branche = 'salon' } = {}) {
const stand = bestellbarerStand({ now: JETZT })
return { ...stand, fragebogen: { ...stand.fragebogen, branche, bestaetigt: [] } }
}
test('PUT fragebogen speichert nur den Fragebogen: version und einstellung der Einstellung bleiben, updatedAt/updatedBy ändern sich, Antwort { fragebogen, offen }', async () => {
setzeZustand('off')
setzeZustand('off', { stand: standOhneBestaetigung() })
const vorher = termineConfigDoc()
const res = await putFragebogen(beispielFragebogen())
assert.equal(res.status, 200, JSON.stringify(res.body))
@@ -1394,7 +1411,7 @@ test('PUT fragebogen speichert nur den Fragebogen: version und einstellung der E
})
test('PUT fragebogen: bereinigt die Eingabe wie normalisiereFragebogen (Reihenfolge, Doppelte, Fremdes, Steuerzeichen) und ersetzt den ganzen Fragebogen', async () => {
setzeZustand('off')
setzeZustand('off', { stand: standOhneBestaetigung() })
const res = await putFragebogen({
branche: 'salon',
bestaetigt: ['website', 'branche', 'branche', 'unbekannt'],
@@ -1459,7 +1476,7 @@ test('PUT fragebogen ohne Einstellung => 409 keine_einstellung, nichts angelegt
for (const zustand of ZUSTAENDE) {
const erwartet = { keins: '409 keine_einstellung', off: '200', ordered: '409 gesperrt', setup: '409 gesperrt', active: '200', disabled: '409 abgeschaltet' }[zustand]
test(`PUT fragebogen: Zustand ${zustand} => ${erwartet}`, async () => {
setzeZustand(zustand)
setzeZustand(zustand, { stand: standOhneBestaetigung() })
const vorher = dokumente()
const res = await putFragebogen(beispielFragebogen())
if (erwartet === '200') {
@@ -1504,7 +1521,8 @@ test('PUT fragebogen: in off nur mit laufendem Wartungs-Abo (409 kein_abo, 503 b
})
test('PUT fragebogen: offen ist die Bestellprüfung des gespeicherten Stands (nur in off), null in active', async () => {
const stand = setzeZustand('off')
const stand = bestellbarerStand({ now: JETZT })
setzeZustand('off', { stand: standOhneBestaetigung() })
const unvollstaendig = await putFragebogen(beispielFragebogen())
assert.equal(unvollstaendig.status, 200)
const erwartet = bestellpruefung({ einstellung: stand.einstellung, fragebogen: unvollstaendig.body.fragebogen, feature: T, abo: { ok: true } })
@@ -1523,13 +1541,66 @@ test('PUT fragebogen: offen ist die Bestellprüfung des gespeicherten Stands (nu
assert.equal((await putFragebogen(beispielFragebogen())).body.offen, null)
})
test('PUT fragebogen: Branche gesundheit lässt sich speichern, das Gate (offen) blockt sie aber', async () => {
setzeZustand('off')
test('PUT fragebogen: Branche gesundheit lässt sich speichern, solange noch keine Branche gespeichert ist; das Gate (offen) blockt sie aber', async () => {
setzeZustand('off', { stand: standOhneBestaetigung({ branche: null }) })
const res = await putFragebogen({ ...bestellbarerStand({ now: JETZT }).fragebogen, branche: 'gesundheit' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.ok(res.body.offen.some((p) => p.schritt === 'branche'))
})
test('B1: ein veralteter Tab (Branche inzwischen gewechselt) bekommt 409 EINSTELLUNG_VERALTET; Branche, Bestätigungen und Preise des neuen Stands bleiben', async () => {
setzeZustand('off')
const tabA = (await send('GET', einstellungenUrl(), { cookie })).body // Tab A zeigt Branche salon, alle Schritte bestätigt, Preise geprüft
assert.equal(tabA.fragebogen.branche, 'salon')
assert.equal(tabA.fragebogen.preiseGeprueft, true)
// Tab B wechselt die Branche: Vorlage ersetzt, Fragebogen beginnt neu (Bestätigungen und Preisprüfung weg)
const tabB = await vorlage({ branche: 'kosmetik', ersetzen: true, version: tabA.version })
assert.equal(tabB.status, 201, JSON.stringify(tabB.body))
const nachB = termineConfigDoc()
// Tab A drückt „Weiter“: PUT mit dem alten Stand
const res = await putFragebogen(tabA.fragebogen)
assert.equal(res.status, 409, JSON.stringify(res.body))
assert.equal(res.body.code, 'EINSTELLUNG_VERALTET')
assert.match(res.body.error, /Bitte neu laden/)
assert.equal(termineConfigDoc().fragebogen, nachB.fragebogen, 'der gespeicherte Fragebogen bleibt byte-gleich')
const gespeichert = gespeicherterFragebogen()
assert.deepEqual([gespeichert.branche, gespeichert.bestaetigt, gespeichert.preiseGeprueft], ['kosmetik', [], false])
const gate = (await send('GET', einstellungenUrl(), { cookie })).body
assert.ok(gate.offen.some((p) => p.schritt === 'leistungen'), 'die ungeprüften Vorlagenpreise gelten nicht als geprüft')
})
test('B1: bestaetigt ist die Vereinigung aus gespeichertem und gesendetem Stand; die übrigen Felder ersetzt der PUT wie bisher', async () => {
const stand = standOhneBestaetigung()
stand.fragebogen.bestaetigt = ['branche', 'wer', 'leistungen'] // Tab B hat schon weiter bestätigt
setzeZustand('off', { stand })
// Tab A kennt nur branche und wer und bestätigt zeiten; seine Preisangabe und sein Einbau gelten (PUT ersetzt den Rest)
const res = await putFragebogen(beispielFragebogen({ bestaetigt: ['branche', 'wer', 'zeiten'], preiseGeprueft: false }))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body.fragebogen.bestaetigt, ['branche', 'wer', 'leistungen', 'zeiten'], 'nichts geht verloren, in der Reihenfolge der Schritte')
assert.equal(res.body.fragebogen.preiseGeprueft, false)
assert.deepEqual(gespeicherterFragebogen(), res.body.fragebogen)
// ohne gespeicherte Bestätigungen oder mit einer Eingabe ohne bestaetigt: nur die Vereinigung zählt
const leer = await putFragebogen({ ...beispielFragebogen(), bestaetigt: undefined })
assert.equal(leer.status, 200)
assert.deepEqual(leer.body.fragebogen.bestaetigt, ['branche', 'wer', 'leistungen', 'zeiten'])
})
test('B1: dieselbe Branche geht durch; eine andere als die gespeicherte (auch null) ist 409, solange eine gespeichert ist', async () => {
setzeZustand('off', { stand: standOhneBestaetigung() })
assert.equal((await putFragebogen(beispielFragebogen({ branche: 'salon' }))).status, 200)
const vorher = dokumente()
for (const branche of ['kosmetik', 'gesundheit', null]) {
const res = await putFragebogen(beispielFragebogen({ branche }))
assert.equal(res.status, 409, String(branche))
assert.equal(res.body.code, 'EINSTELLUNG_VERALTET')
}
assert.equal(dokumente(), vorher, 'nichts geändert')
})
// ---------------------------------------------------------------- PUT: Kundenansicht
for (const zustand of ZUSTAENDE) {
@@ -1553,7 +1624,7 @@ for (const zustand of ZUSTAENDE) {
}
test('PUT fragebogen am Router: dieselbe Anfrage mit der Sitzung des Kunden selbst geht durch (der Router sperrt nur die Kundenansicht)', async () => {
setzeZustand('off')
setzeZustand('off', { stand: standOhneBestaetigung() })
const res = await sendDirekt('PUT', FRAGEBOGEN_URL, { cookie, body: { projectId: 'p1', fragebogen: beispielFragebogen() } })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(gespeicherterFragebogen(), beispielFragebogen())
+139 -8
View File
@@ -337,6 +337,23 @@ test('zusammenfassung (Vorlage salon): Name, Branche und Anschrift', () => {
assert.ok(zeilen.includes('Anschrift: Hauptstraße 1, 67655 Kaiserslautern'))
})
test('B5: zusammenfassung nennt in „Branche und Betrieb“ ausdrücklich „Kleinunternehmer: ja“ oder „nein“; ohne Angabe keine Zeile', () => {
const zeilen = (einstellung, fragebogen) => gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'branche').zeilen
const nein = stand()
assert.equal(nein.einstellung.betrieb.kleinunternehmer, false)
assert.deepEqual(zeilen(nein.einstellung, nein.fragebogen).filter((z) => z.startsWith('Kleinunternehmer')), ['Kleinunternehmer: nein'])
const ja = stand((e) => { e.betrieb.kleinunternehmer = true })
assert.deepEqual(zeilen(ja.einstellung, ja.fragebogen).filter((z) => z.startsWith('Kleinunternehmer')), ['Kleinunternehmer: ja'])
assert.deepEqual(zeilen(ja.einstellung, ja.fragebogen).slice(0, 4), ['Branche: Friseur und Salon', 'Betrieb: Salon Muster', 'Anschrift: Hauptstraße 1, 67655 Kaiserslautern', 'Kleinunternehmer: ja'], 'nach der Anschrift')
// fehlt die Angabe oder ist sie kein Wahrheitswert, wird nichts erfunden
for (const wert of [undefined, null, 'ja', 1]) {
const ohne = stand((e) => { e.betrieb.kleinunternehmer = wert })
assert.ok(!zeilen(ohne.einstellung, ohne.fragebogen).some((z) => z.startsWith('Kleinunternehmer')), String(wert))
}
const keine = zusammenfassung(null, null, BRANCHEN)
assert.ok(!JSON.stringify(keine).includes('Kleinunternehmer'))
})
test('zusammenfassung (Vorlage salon): Leistungen mit Preis', () => {
const { einstellung, fragebogen } = stand()
const zeilen = gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'leistungen').zeilen
@@ -1013,7 +1030,7 @@ test('funktion-bestellen.js statisch: kein termine*.js, relative Importe, von ke
assert.doesNotMatch(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8'), /from '\/funktion-bestellen\.js'|import\('\/funktion-bestellen\.js'\)/, `${datei}: kein absoluter Import`)
}
const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"], 'funktionen.js behält genau diese beiden absoluten Importe')
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/fn-util.js'", "from '/analyse-view.js'"], 'funktionen.js behält genau diese beiden absoluten Importe')
assert.doesNotMatch(fn, /funktion-bestellen/)
})
@@ -1189,16 +1206,64 @@ test('renderBestellen: Zusammenfassung zeigt die acht Gruppen aus zusammenfassun
assert.deepEqual(schritte, ['leistungen', 'website'])
}))
test('renderBestellen: eine Gruppe ohne Angaben sagt es und hat trotzdem „Ändern“; fehlendes zumSchritt ist kein Fehler', mitDom(undefined, async (fx) => {
test('renderBestellen: eine Gruppe ohne Angaben sagt es und hat trotzdem „Ändern“ (mit zumSchritt)', mitDom(undefined, async (fx) => {
const daten = await bestellDaten({ einstellung: null, fragebogen: null })
const wurzel = h('div')
document.body.append(wurzel)
const ctx = huelle.baueCtx({ daten, nurLesen: false }, { projekt: { projectId: 'p1' }, aktuell: () => true })
bestellen.renderBestellen(wurzel, { ctx, daten })
const { wurzel, schritte } = await bestellSeite(daten)
const gruppen = fx.alle(wurzel, '.tm-bestellen-gruppe')
assert.equal(gruppen.length, 8)
assert.match(fx.text(gruppen[0]), /Noch keine Angaben/)
await fx.klick(fx.finde(gruppen[0], '[data-aktion="aendern"]'))
assert.deepEqual(schritte, ['branche'])
}))
test('B4: ohne zumSchritt (keine Funktion) zeichnet die Bestellseite weder „Ändern“ noch „Zum Schritt“ - keine Knöpfe, die nichts tun', mitDom(undefined, async (fx) => {
const offen = [{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' }, { schritt: 'bestellen', text: 'Das Abo konnte gerade nicht geprüft werden.' }]
for (const zumSchritt of [undefined, null, 'website', 42, {}]) {
const daten = await bestellDaten({ offen })
const wurzel = h('div')
document.body.append(wurzel)
const ctx = huelle.baueCtx({ daten, nurLesen: false }, { projekt: { projectId: 'p1' }, aktuell: () => true })
bestellen.renderBestellen(wurzel, { ctx, daten, zumSchritt })
const name = JSON.stringify(zumSchritt)
assert.equal(fx.alle(wurzel, '.tm-bestellen-gruppe').length, 8, `${name}: die Zusammenfassung steht`)
assert.equal(fx.alle(wurzel, '[data-aktion="aendern"]').length, 0, `${name}: kein „Ändern“`)
assert.equal(fx.alle(wurzel, '[data-aktion="zum-schritt"]').length, 0, `${name}: kein „Zum Schritt“`)
assert.deepEqual(fx.alle(wurzel, '.tm-bestellen-offen li span').map((span) => span.textContent), offen.map((p) => p.text), `${name}: die offenen Punkte bleiben als Text`)
}
// mit Funktion ist alles wie bisher da
const { wurzel } = await bestellSeite(await bestellDaten({ offen }))
assert.equal(fx.alle(wurzel, '[data-aktion="aendern"]').length, 8)
assert.deepEqual(fx.alle(wurzel, '[data-aktion="zum-schritt"]').map((k) => k.dataset.schritt), ['website'])
}))
test('B4: nach einem 409 bekommt die Meldung den Fokus (tabindex -1), auf der neuen Seite, bei fehlgeschlagenem Neuladen und auf der Hinweiskarte', mitDom(undefined, async (fx) => {
const fokusAufMeldung = (wurzel, was) => {
const meldung = fx.finde(wurzel, '[data-meldung="bestellen"]')
assert.ok(meldung, was)
assert.equal(meldung.getAttribute('tabindex'), '-1', `${was}: fokussierbar, aber nicht im Tabulatorweg`)
assert.equal(fx.document.activeElement, meldung, `${was}: Fokus auf der Meldung`)
}
// a) Seite wird neu gezeichnet
fx.antworten.push({ status: 409, body: { error: 'Die Bestellung ist noch nicht vollständig.', code: 'bestellung_offen', offen: [{ schritt: 'website', text: 'x' }] } }, { body: await bestellDaten() })
const a = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, a.wurzel)
await fx.klick(bestellKnopf(fx, a.wurzel))
fokusAufMeldung(a.wurzel, 'neu gezeichnet')
// b) Neuladen scheitert: dieselbe Seite, Meldung mit Fokus
fx.antworten.push({ status: 409, body: { error: 'Dein Wartungs-Abo ist gekündigt.', code: 'gekuendigt' } }, { status: 500, body: { error: 'Server kaputt.' } })
const b = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, b.wurzel)
await fx.klick(bestellKnopf(fx, b.wurzel))
fokusAufMeldung(b.wurzel, 'Neuladen gescheitert')
// c) die Bestellung ist inzwischen aufgegeben: die Karte mit der Meldung bekommt den Fokus
fx.antworten.push({ status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, { body: await bestellDaten({ state: 'ordered', bestellung: null, offen: null }) })
const c = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, c.wurzel)
await fx.klick(bestellKnopf(fx, c.wurzel))
const karteEl = fx.finde(c.wurzel, '.tm-info')
assert.ok(karteEl)
assert.equal(karteEl.getAttribute('tabindex'), '-1')
assert.equal(fx.document.activeElement, karteEl, 'Fokus auf der Hinweiskarte')
}))
test('renderBestellen: offene Punkte mit „Zum Schritt“ zu ihrem Schritt, der Abo-Punkt (bestellen) ohne Knopf; Knopf gesperrt', mitDom(undefined, async (fx) => {
@@ -1443,6 +1508,41 @@ test('statusKarte ordered: „Zahlung fortsetzen“ sendet fortsetzenKoerper und
assert.equal(fx.window.location.href, KNOPF_URL)
}))
test('B3: zurück aus dem Bfcache (pageshow persisted) lädt die Karte „Zahlung fortsetzen“ neu - nicht gesperrt und ohne Meldung stehen lassen', mitDom({ antworten: [{ body: { url: KNOPF_URL } }] }, async (fx) => {
const wurzel = karte()
const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]')
await fx.klick(knopf)
assert.equal(fx.window.location.href, KNOPF_URL)
assert.equal(knopf.disabled, true, 'bis zum Verlassen der Seite gesperrt')
// ein normales pageshow (erste Anzeige) ändert nichts
fx.window.dispatchEvent({ type: 'pageshow', persisted: false })
assert.deepEqual(fx.reloads, [])
// Zurück aus der Zahlungsseite: der Browser stellt die Seite samt gesperrtem Knopf wieder her
fx.window.dispatchEvent({ type: 'pageshow', persisted: true })
assert.equal(fx.reloads.length, 1, 'die Seite wird neu geladen')
}))
test('B3: eine Karte, die nicht mehr im Baum steht, lädt nichts neu und ihr Handler verschwindet', mitDom(undefined, (fx) => {
const wurzel = karte()
assert.equal(fx.window.anzahlHandler('pageshow'), 1)
wurzel.remove()
fx.window.dispatchEvent({ type: 'pageshow', persisted: true })
assert.deepEqual(fx.reloads, [])
assert.equal(fx.window.anzahlHandler('pageshow'), 0)
}))
test('B3: auch die Bestellseite lädt bei der Rückkehr aus dem Bfcache neu, solange sie im Baum steht', mitDom({ antworten: [{ body: { url: KNOPF_URL } }] }, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, wurzel)
await fx.klick(bestellKnopf(fx, wurzel))
assert.equal(fx.window.location.href, KNOPF_URL)
assert.equal(bestellKnopf(fx, wurzel).disabled, true)
fx.window.dispatchEvent({ type: 'pageshow', persisted: false })
assert.deepEqual(fx.reloads, [])
fx.window.dispatchEvent({ type: 'pageshow', persisted: true })
assert.equal(fx.reloads.length, 1)
}))
test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter({ bestellen: true, meldung }) lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({
antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
}, async (fx) => {
@@ -3099,8 +3199,9 @@ test('Ansicht off ohne laufendes Abo: der Hinweis vom Server (kein_abo mit Link
const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]')
assert.ok(karteEl)
assert.ok(fx.text(karteEl).includes(SERVER_HINWEIS), 'der Text des Servers, unverändert')
const link = fx.alle(karteEl, 'button')
const link = fx.alle(karteEl, 'button[data-view]')
assert.deepEqual(link.map((b) => [fx.text(b), b.getAttribute('data-view'), b.getAttribute('class'), b.getAttribute('type')]), [['Zu Verträge', 'vertraege', 'kb-link', 'button']])
assert.deepEqual(fx.alle(karteEl, 'button').map((b) => b.getAttribute('data-aktion') ?? b.getAttribute('data-view')), ['vertraege', 'neu-laden'], 'dazu „Neu laden“ (B2)')
assert.equal(fx.text(fx.finde(karteEl, 'p')), `${SERVER_HINWEIS} Zu Verträge`, 'Text, Leerzeichen, Link wie in der Kachel')
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen')
assert.equal(fx.alle(wurzel, 'input').length, 0)
@@ -3120,11 +3221,41 @@ test('Ansicht off ohne Abo: gekündigt, nicht aktiv, nicht lesbar und fehlendes
const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]')
assert.ok(karteEl, name)
assert.equal(fx.text(fx.finde(karteEl, 'p')), termine.hinweis, name)
assert.equal(fx.alle(karteEl, 'button').length, 0, `${name}: kein Link`)
assert.equal(fx.alle(karteEl, 'button[data-view]').length, 0, `${name}: kein Link`)
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, name)
}
}))
test('B2: der Text zum Abo kommt mit den Daten (aboHinweis), auch wenn die Liste „bestellbar“ sagt und das Abo nicht lesbar war; Link bei kein_abo; „Neu laden“ holt alles neu', mitDom(undefined, async (fx) => {
// das Abo war beim Lesen der Daten nicht lesbar (abo null), die Liste kennt keinen Hinweis: früher stand nur die Überschrift da
const HINWEIS = 'Das Abo konnte gerade nicht geprüft werden.'
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: null, aboHinweis: HINWEIS } }) })
const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]')
assert.ok(karteEl)
assert.equal(fx.text(fx.finde(karteEl, 'p')), HINWEIS, 'der Text des Servers aus den Daten')
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')], 'dafür braucht es keine weitere Abfrage der Liste')
assert.equal(fx.alle(karteEl, 'button[data-view]').length, 0, 'ohne Grund kein_abo kein Link')
// kein_abo steht in den Daten: der Link zu Verträge auch ohne Angaben der Liste
const kein = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'kein_abo' }, aboHinweis: SERVER_HINWEIS } }) })
const kleiner = fx.finde(kein, '[data-hinweis="abo"]')
assert.equal(fx.text(fx.finde(kleiner, 'p')), `${SERVER_HINWEIS} Zu Verträge`)
// „Neu laden“: Daten noch einmal holen und die Ansicht nach dem neuen Stand zeichnen (das Abo läuft inzwischen: der Fragebogen)
const neu = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', {
daten: { abo: null, aboHinweis: HINWEIS },
tabelle: { [an('p1')]: [{ body: await ansichtDaten('off', { abo: null, aboHinweis: HINWEIS }) }, { body: await ansichtDaten('off', { fragebogen: fbBis(1) }) }] },
}) })
const laden = fx.finde(neu, '[data-hinweis="abo"] button[data-aktion="neu-laden"]')
assert.ok(laden, 'Knopf „Neu laden“')
assert.equal(fx.text(laden), 'Neu laden')
assert.equal(laden.getAttribute('type'), 'button')
await fx.klick(laden)
await fx.ruhe()
assert.ok(fx.finde(neu, '[data-hinweis="abo"]') === null, 'die Karte ist weg')
assert.ok(fx.finde(neu, 'nav.tm-fortschritt'), 'der Fragebogen steht da')
}))
test('Ansicht off ohne Abo: der Hinweis kommt maskiert in die Seite und der Browser kennt keinen eigenen Text', mitDom(undefined, async (fx) => {
const bose = '<img src=x onerror=alert(1)> & "Anführungszeichen"'
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt', bose) }) })
+25
View File
@@ -315,6 +315,31 @@ test('meldeFreigeschaltet: Kunde nicht lesbar oder Funktion nicht im Register =>
assert.equal(mails.sent.length, 0)
})
test('meldeFreigeschaltet liefert true nur, wenn K16 rausging: Erfolg true; Versandfehler, nicht lesbarer Kunde, unbekannte Funktion, Kunde ohne oder unbekannt false (A2)', async (t) => {
STUMM(t)
const doc = einrichtung('termine', { einstellung: true })
assert.equal(await meldeFreigeschaltet(doc), true)
assert.equal(mails.sent.length, 1)
setMailTransportForTests({
sendMail: async () => {
throw new Error('SMTP: Postfach voll')
},
})
try {
assert.equal(await meldeFreigeschaltet(doc), false, 'Versandfehler')
} finally {
setMailTransportForTests(mails)
}
aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Nachbau: Kunden kaputt' })
assert.equal(await meldeFreigeschaltet(doc), false, 'Kunde nicht lesbar')
assert.equal(await meldeFreigeschaltet(einrichtung('gibtsnicht')), false, 'Funktion nicht im Register')
aw.seed('customers', { $id: 'c_leer2', appwriteUserId: 'u_leer2', email: '', name: 'Ohne Adresse', status: 'active' })
assert.equal(await meldeFreigeschaltet(einrichtung('termine', { customerId: 'c_leer2' })), false, 'Kunde ohne Adresse')
assert.equal(await meldeFreigeschaltet(einrichtung('termine', { customerId: 'c_gibtsnicht' })), false, 'Kunde unbekannt')
assert.equal(mails.sent.length, 1, 'nur die erste Mail ging raus')
})
// -------------------------------------------------------------------------------------------- runActivation ---
test('runActivation sendet fuer ein aktiviertes Termine-Dokument genau ein K16 (ueber meldeFreigeschaltet), ein zweiter Lauf keins', async () => {
const doc = einrichtung('termine', { einstellung: true })
+135
View File
@@ -1921,6 +1921,54 @@ test('Löschung nach 30 Tagen, R108: der übersprungene Reset zählt im Tagesjob
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off')
})
// C2: bezahlte Einrichtung, abgeschaltet wegen des Abo-Endes vor der Einrichtung, T19 (Erstattung) noch nicht beim Team: der Reset nach
// 30 Tagen wuerde zahlungMeldung, unlockedByPurchase und ticketId loeschen und damit die Spur der geschuldeten Erstattung.
const ERSTATTUNG_OFFEN = ['', 'abo_ende', 'bereits_aktiv']
for (const meldung of ERSTATTUNG_OFFEN) {
test(`Löschung nach 30 Tagen, C2: bezahlt + abo_ende_vor_einrichtung + zahlungMeldung "${meldung}" (T19 offen) ⇒ Daten weg, Dokument bleibt disabled mit allen Markern; Warnung ohne Kennungen`, async () => {
await abgeschaltetMitBuchungen(2, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende_vor_einrichtung', zahlungMeldung: meldung, aboEndeMailAt: null }) })
const vorher = structuredClone(aw.docs('portalFeatures').get(F1))
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 2, gedeckelt: 0 }, 'gelöscht wird wie bisher')
assert.equal(buchungen().length, 0)
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht')
const doc = aw.docs('portalFeatures').get(F1)
assert.deepEqual({ ...doc, $updatedAt: undefined }, { ...vorher, $updatedAt: undefined }, 'das Dokument ist unverändert (nicht zurückgesetzt)')
assert.equal(warnLog().filter((zeile) => /offenen Zahlungsfall/.test(zeile)).length, 1)
assert.doesNotMatch(warnLog().join('\n'), /\bb_|f_p_|p_projekt|c_kunde|Müller|@/, 'keine Kennung, keine Personendaten im Log')
assert.equal(fehlerLog().length, 0, 'kein Wurf')
})
}
test('Löschung nach 30 Tagen, C2: unbezahlt (unlockedByPurchase false) oder T19 gemeldet ⇒ zurückgesetzt wie bisher', async () => {
await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende_vor_einrichtung', zahlungMeldung: 'gemeldet' }) })
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 })
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off', 'T19 gemeldet')
await abgeschaltetMitBuchungen(1, { zellen: false, praefix: 'b_un', funktion: eingerichtet({ disabledReason: 'abo_ende_vor_einrichtung', zahlungMeldung: '', unlockedByPurchase: false, purchaseStatus: 'ordered', aboEndeMailAt: null }) })
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 })
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off', 'unbezahlt: es gibt nichts zu erstatten')
assert.equal(warnLog().length, 0)
})
test('Tagesjob, C2: T19 scheitert 31 Tage lang ⇒ der Reset wird nicht ausgeführt (Fehler gezählt); sobald T19 beim Team ist, setzt ein Lauf zurück und T19 ging genau einmal', async () => {
config.chatNotify.emails = ['team@example.com']
await abgeschaltetMitBuchungen(2, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende_vor_einrichtung', zahlungMeldung: '', aboEndeMailAt: null }) })
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP nicht erreichbar') } })
let erg = await taeglicherJob(JETZT)
assert.ok(erg.fehler >= 1, `Fehler gezählt: ${erg.fehler}`)
let doc = aw.docs('portalFeatures').get(F1)
assert.deepEqual([doc.state, doc.unlockedByPurchase, doc.zahlungMeldung, doc.ticketId, doc.disabledReason], ['disabled', true, '', 't_einrichtung', 'abo_ende_vor_einrichtung'], 'die Spur der Erstattung bleibt')
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'die Daten sind trotzdem gelöscht')
setMailTransportForTests(sink)
for (const tage of [1, 2]) await taeglicherJob(plus(JETZT, tage * TAG))
doc = aw.docs('portalFeatures').get(F1)
assert.deepEqual([doc.state, doc.purchaseStatus, doc.unlockedByPurchase, doc.zahlungMeldung], ['off', 'zurueckgesetzt', false, ''], 'nach der Meldung wird zurückgesetzt')
assert.equal(sink.sent.filter((m) => /Erstattung fällig/.test(m.subject)).length, 1, 'T19 genau einmal')
})
for (const meldung of ['', 'gemeldet']) {
test(`Löschung nach 30 Tagen, R108: zahlungMeldung "${meldung}" ist kein offener Fall ⇒ zurückgesetzt wie bisher`, async () => {
await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende', zahlungMeldung: meldung }) })
@@ -2968,3 +3016,90 @@ test('server/index.js: die drei Jobs laufen alle 60 s, alle 5 min und täglich (
}
}
})
// ---------------------------------------------------------------- Webshop: die Mail-Warteschlange gehört auch dem Shop (E4)
const SHOP_MODUS_VORHER = config.shop.mode
afterEach(() => {
config.shop.mode = SHOP_MODUS_VORHER
})
test('Webshop aktiv, Terminplaner off: der Minutenjob sendet die Warteschlange, der Verfall und alles andere bleibt aus', async () => {
await richteFunktionEin()
await legeBuchung('b_abgelaufen', { status: 'gehalten', heldUntil: iso(plus(JETZT, -5 * MIN)) })
legeEintrag('o_faellig', { art: 'G09' })
legeEintrag('o_spaeter', { art: 'G09', naechsterVersuch: iso(plus(JETZT, 10 * MIN)) })
config.termine.mode = 'off'
config.shop.mode = 'pilot'
assert.deepEqual(await minutenJob(JETZT), { eingeschraenkt: true, verfallen: 0, faellig: 1, gesendet: 1, fehler: 0 })
assert.equal(sink.sent.length, 1)
assert.equal(aw.docs('termineOutbox').get('o_faellig').status, 'gesendet')
assert.equal(aw.docs('termineOutbox').get('o_spaeter').status, 'wartet')
assert.equal(status('b_abgelaufen'), 'gehalten', 'der Verfall der Haltungen bleibt aus')
// Shop aus, Terminplaner off: wie bisher nichts
config.shop.mode = 'off'
legeEintrag('o_nochmal', { art: 'G09' })
assert.deepEqual(await minutenJob(JETZT), { aus: true })
assert.equal(aw.docs('termineOutbox').get('o_nochmal').status, 'wartet')
})
test('Webshop aktiv, Terminplaner off und ohne bezahlte Terminplaner: der Tagesjob beendet festgefahrene Mails und räumt die Warteschlange auf, sonst nichts', async () => {
await richteFunktionEin() // aktiv, nicht bezahlt
await legeLoeschFall('b_verfallen', 'verfallen', 1)
legeEintrag('o_festgefahren', { art: 'G09', createdAt: iso(plus(JETZT, -3 * TAG)) })
legeEintrag('o_gesendet', { art: 'G09', status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) })
legeEintrag('o_neu', { art: 'G09' })
config.termine.mode = 'off'
config.shop.mode = 'on'
const erg = await taeglicherJob(JETZT)
assert.deepEqual(erg, {
eingeschraenkt: true,
aboAbgleich: { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 },
abgeschaltet: { funktionen: 0, buchungen: 0 },
gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 },
geloescht: 0,
aktiveBetriebe: null,
festgefahren: 1,
aufgeraeumt: 1,
fehler: 0,
})
assert.equal(aw.docs('termineOutbox').get('o_festgefahren').status, 'fehler')
assert.equal(aw.docs('termineOutbox').get('o_gesendet'), undefined, 'gesendeter Eintrag gelöscht')
assert.equal(aw.docs('termineOutbox').get('o_neu').status, 'wartet')
assert.ok(buchung('b_verfallen'), 'keine Löschfristen der Buchungen')
assert.equal(
tagesbericht(erg),
'[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung sowie Mail-Warteschlange): 0 Buchungen gelöscht (abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 1 Outbox, 1 Mails nicht zugestellt, Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler',
)
})
test('Webshop aktiv, Terminplaner off, bezahlter Terminplaner: Abo-Abgleich und Löschung laufen wie bisher, dazu die Warteschlange', async () => {
await richteFunktionEin(F1, BEZAHLT)
wartung('active')
legeEintrag('o_festgefahren', { art: 'G09', createdAt: iso(plus(JETZT, -3 * TAG)) })
config.termine.mode = 'off'
config.shop.mode = 'pilot'
const erg = await taeglicherJob(JETZT)
assert.equal(erg.eingeschraenkt, true)
assert.equal(erg.aboAbgleich.geprueft, 1)
assert.equal(erg.festgefahren, 1)
assert.equal(erg.aufgeraeumt, 0)
assert.equal(aw.docs('termineOutbox').get('o_festgefahren').status, 'fehler')
})
test('Webshop aktiv, Terminplaner off: schlägt die Guard-Abfrage fehl, läuft nur die Warteschlange (Warnung ohne Kennungen)', async () => {
legeEintrag('o_festgefahren', { art: 'G09', createdAt: iso(plus(JETZT, -3 * TAG)) })
config.termine.mode = 'off'
config.shop.mode = 'on'
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt' })
const erg = await taeglicherJob(JETZT)
assert.equal(erg.eingeschraenkt, true)
assert.equal(erg.festgefahren, 1)
assert.equal(warnLog().filter((zeile) => /taeglicherJob/.test(zeile) && /Nachbau kaputt/.test(zeile)).length, 1)
assert.doesNotMatch(warnLog().join('\n'), /\bb_|\bf_|\bp_|\bc_|@/)
})
+703
View File
@@ -0,0 +1,703 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { config } from '../server/config.js'
import { textformNachweisText } from '../server/services/siteFeatures.js'
import {
RECHTSTEXTE_VERSION,
GESUNDHEIT_HINWEIS,
ZUSTIMMUNG_ERINNERUNG,
ZUSTIMMUNG_STORNO,
ZUSTIMMUNG_VORZEITIG,
datenschutzAbsatz,
formularHinweis,
widerrufsbelehrung,
widerrufsbelehrungFunktion,
} from '../server/services/termine/rechtstexte.js'
import { knopfText } from '../server/services/termine/einstellungen.js'
import { VORLAGEN } from '../server/services/termine/vorlagen.js'
import { LOESCHFRIST_TAGE } from '../server/services/termine/frist.js'
import { FREIZEIT_HINWEIS, STORNO_MUSTER_HINWEIS, WIDERRUF_HINWEIS } from '../public/termine-tab-einstellungen.js'
// Kanzlei-Paket Terminplaner (Tasks F15a und F15b): docs/rechtliches/termine-kanzlei-paket.md. Das Paket zitiert Texte, die im
// Programm und in den Dokumenten stehen; dieser Test prueft, dass jedes Zitat wortgleich in einer Quelle steht, dass die Zahlen
// zum Code passen und dass die Fundstellen existieren. Aendert sich ein Text (neues Fassungsdatum), muss das Paket nachgezogen werden.
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r/g, '')
const norm = (text) => text.replace(/\s+/g, ' ').trim()
const PAKET_PFAD = 'docs/rechtliches/termine-kanzlei-paket.md'
const PAKET = lies(PAKET_PFAD)
const F = config.termine.avvVersion
const V = RECHTSTEXTE_VERSION
// ------------------------------------------------------------------------------------------------ Aufbau des Pakets
const UEBERSCHRIFTEN = [
'## 1. Zweck und Umfang',
'## 2. Produktablauf in Kurzform',
'## 3. Texte in Endfassung',
'## 4. Fragen an die Kanzlei',
'## 5. Fundstellen im Code',
'## 6. Nicht Teil der Kanzleiprüfung',
'## 7. Checkliste für den Inhaber',
]
/** Text eines Abschnitts (## n. …) ohne die Überschrift; bis zur nächsten ##-Überschrift. */
function abschnitt(nr) {
const zeilen = PAKET.split('\n')
const start = zeilen.findIndex((z) => z.startsWith(`## ${nr}. `))
assert.ok(start >= 0, `Abschnitt ${nr} fehlt`)
let ende = zeilen.findIndex((z, i) => i > start && /^## /.test(z))
if (ende < 0) ende = zeilen.length
return zeilen.slice(start + 1, ende).join('\n')
}
/** Zitatblöcke eines Textes: zusammenhängende Zeilen mit „>“; Absätze durch eine Zeile „>“ getrennt. Das maskierte Sternchen „\*“ gilt als „*“. */
function zitatBloecke(text) {
const zeilen = text.split('\n')
const bloecke = []
let aktuell = null
zeilen.forEach((zeile, i) => {
if (/^>/.test(zeile)) {
if (!aktuell) {
// Zeile vor dem Block (die letzte nicht leere Zeile, die kein Zitat ist): Überschrift des Zitats
let k = i - 1
while (k >= 0 && zeilen[k].trim() === '') k--
aktuell = { zeilen: [], start: i, ende: i, ueberschrift: k >= 0 ? zeilen[k] : '' }
bloecke.push(aktuell)
}
aktuell.zeilen.push(zeile.replace(/^> ?/, ''))
aktuell.ende = i
} else {
aktuell = null
}
})
return bloecke.map((b) => {
const absaetze = []
let teil = []
for (const z of b.zeilen) {
if (z.trim() === '') {
if (teil.length) absaetze.push(teil)
teil = []
} else {
teil.push(z)
}
}
if (teil.length) absaetze.push(teil)
return {
start: b.start,
ende: b.ende,
ueberschrift: b.ueberschrift,
absaetze: absaetze.map((a) => norm(a.join(' ').replace(/\\([*<])/g, '$1'))),
}
})
}
/** Alle Fragenummern hinter einem ⚖ (Marker „⚖ Q3, Q5“ oder Zellen einer Tabelle). */
function marker(text) {
const nummern = []
for (const m of text.matchAll(/⚖((?:\s*(?:Q\d+)(?:\s*[,·]\s*|\s+und\s+)?)+)/g)) {
for (const q of m[1].matchAll(/Q(\d+)/g)) nummern.push(Number(q[1]))
}
return nummern
}
test('paket_aufbau: sieben Überschriften, beide Fassungen und alle Teile von Abschnitt 3', () => {
const ueberschriften = PAKET.split('\n').filter((z) => /^## /.test(z))
assert.deepEqual(ueberschriften, UEBERSCHRIFTEN)
assert.equal(PAKET.split('\n').filter((z) => /^# /.test(z)).length, 1, 'genau eine Titelzeile')
assert.match(F, /^\d{4}-\d{2}-\d{2}$/)
assert.match(V, /^\d{4}-\d{2}-\d{2}$/)
const kopf = PAKET.split('\n## 1.')[0]
assert.ok(kopf.includes('`config.termine.avvVersion`') && kopf.includes(`**${F}**`), 'Fassung der Vertragsunterlagen im Kopf')
assert.ok(kopf.includes('`RECHTSTEXTE_VERSION`') && kopf.includes(`**${V}**`), 'Fassung der Gästetexte im Kopf')
// Abschnitt 1: Kernpaket, Hausrunde, keine Rechtsberatung, Fassungen
const eins = abschnitt(1)
for (const wort of ['Kernpaket', 'Hausrunde', 'Keine Rechtsberatung durch WEBklar', 'Fassungen']) assert.ok(eins.includes(wort), wort)
// Begriffe, die die Kanzlei sonst nicht kennt: Betriebsart (Modus), Pilot, Kulanz; und die nachgereichte Anlage C
for (const wort of ['**Begriffe.**', 'Betriebsart', '`pilot`', '`on`', 'Kulanz', 'Anlage C']) assert.ok(eins.includes(wort), wort)
assert.ok(abschnitt(7).includes('**Anlage C**'), 'Checkliste nennt Anlage C (K15 und K16 gerendert)')
for (const n of [3, 20, 21]) assert.ok(PAKET.includes(`### Q${n}:`))
// Abschnitt 2: die Schritte des Ablaufs in der vorgegebenen Reihenfolge
const zwei = abschnitt(2)
const reihenfolge = ['| 1. Abo-Prüfung |', '| 2. Fragebogen-Entwurf |', '| 3. Bestellung mit vier Haken |', '| 4. Nachweis |', '| 5. Zahlung |', '| 6. Ticket und Bestätigung |', '| 7. Einbau und Freischaltung |', '| 8. Betrieb |']
let pos = -1
for (const schritt of reihenfolge) {
const i = zwei.indexOf(schritt)
assert.ok(i > pos, `Schritt „${schritt}“ fehlt oder steht an der falschen Stelle`)
pos = i
}
for (const wort of ['30 Minuten', 'Bestätigungslink', 'Vertrag widerrufen', 'spätestens am folgenden Tag', '30 Tage', 'Rückkehr', 'vollständig']) assert.ok(zwei.includes(wort), wort)
// Abschnitt 3: die Unterabschnitte 3.1 bis 3.11
const drei = abschnitt(3)
const teile = [...drei.matchAll(/^### (3\.\d+) /gm)].map((m) => m[1])
assert.deepEqual(teile, Array.from({ length: 11 }, (_, i) => `3.${i + 1}`))
// Abschnitt 4 existiert (die Fragen selbst prüft paket_marker_und_fragen)
assert.ok(abschnitt(4).trim().length > 0)
assert.ok(abschnitt(7).includes('- [ ]'), 'Checkliste als Aufgabenliste')
})
// ------------------------------------------------------------------------------------------------ Zitate
/** Text einer Mailvorlage ohne Markup (nur für den Wortlautvergleich); Inline-Auszeichnungen fallen ohne Leerzeichen weg. */
function mailText(rel) {
return norm(
lies(rel)
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<style[\s\S]*?<\/style>/g, ' ')
.replace(/<script[\s\S]*?<\/script>/g, ' ')
.replace(/<\/?(?:strong|b|em|i|u|span|a)(?:\s[^>]*)?>/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&'),
)
}
const PLATZHALTER_BETRIEB = { name: '{{Name des Betriebs}}', adresse: '{{Anschrift}}', telefon: '{{Telefon}}', email: '{{E-Mail}}' }
const PLATZHALTER_EINSTELLUNG = (anrede) => ({
anrede,
aufbewahrungTage: 90,
betrieb: { name: '{{Firma}}', adresse: '{{Anschrift}}', email: '{{E-Mail}}', telefon: '{{Telefon}}' },
gastFelder: { telefon: 'aus', nachricht: 'aus', zusatz: [] },
erinnerung: { anbieten: false },
})
/**
* Chat-Vorlage des Ticketsystems (WOMS, src/lib/funktionenView.js, termineChatTemplate), mit Platzhaltern statt Fassung und Adressen.
* Das WOMS liegt in einem eigenen Repository; test woms_chat_vorlage_gleich gleicht diese Kopie mit dem Quelltext ab, wenn er erreichbar ist.
*/
const WOMS_CHAT_VORLAGE =
'Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. ' +
'Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung <Fassung>: ' +
'<Adresse der Leistungsbeschreibung> · <Adresse des AVV>. ' +
'Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.'
/** Alle Quellen, in denen ein Zitat stehen darf (je Quelle whitespace-normalisiert). */
function quellen() {
const t = config.features.termine
const n = config.features.news
const roh = [
// Register: Haken und Knöpfe der Bestellung, Umsatzsteuerhinweis
...t.consent.items.map((i) => i.text),
t.consent.knopf,
...n.consent.items.map((i) => i.text),
n.consent.knopf,
config.ustHinweis,
// Ausgaben von rechtstexte.js
widerrufsbelehrungFunktion(),
widerrufsbelehrung(PLATZHALTER_BETRIEB),
formularHinweis(PLATZHALTER_EINSTELLUNG('sie')),
formularHinweis(PLATZHALTER_EINSTELLUNG('du')),
datenschutzAbsatz(PLATZHALTER_EINSTELLUNG('sie'), 'sie'),
datenschutzAbsatz(PLATZHALTER_EINSTELLUNG('du'), 'du'),
ZUSTIMMUNG_VORZEITIG('{{Firma}}'),
ZUSTIMMUNG_STORNO,
ZUSTIMMUNG_ERINNERUNG('{{n}}'),
GESUNDHEIT_HINWEIS,
knopfText({ buchungsart: 'anfrage' }),
knopfText({ buchungsart: 'anfrage' }, { bestaetigen: true }),
knopfText({ preisArt: 'fest', preisCent: 5000 }),
knopfText({ preisArt: 'fest', preisCent: 5000 }, { bestaetigen: true }),
knopfText({ preisArt: 'keiner' }),
knopfText({ preisArt: 'keiner' }, { bestaetigen: true }),
// die drei Markdown-Dokumente der Vertragsunterlagen
lies('docs/rechtliches/leistungsbeschreibung-terminplaner.md'),
lies('docs/rechtliches/avv-terminplaner.md'),
lies('docs/rechtliches/widerrufsbelehrung-funktionen.md'),
// Hinweise-Dokument der Gäste (Knopf „Vertrag widerrufen“ und „Widerruf bestätigen“ der Widerrufsfunktion)
lies('docs/rechtliches/termine-hinweise.md'),
// Mailvorlage G02 (Hinweise zum Widerrufsrecht in der Bestätigung)
mailText('server/mail-templates/G02 Termin bestaetigt.html'),
// Datenschutzerklärung von WEBklar (Absatz zur Funktionsbestellung in § 7, zitiert in Q23)
mailText('public/datenschutz.html'),
// Zusatz zur Preiszeile der Bestellbestätigung K15 (Q19)
t.mails.preisHinweis,
// Nachweis der Zustimmung in Textform bei Freischaltung durch das Team (Q21), mit Platzhaltern für Fassung, Datum und Weg
textformNachweisText({ date: '<JJJJ>-<MM>-<TT>', channel: 'chat' }, '<Fassung>'),
textformNachweisText({ date: '<JJJJ>-<MM>-<TT>', channel: 'email' }, '<Fassung>'),
// Chat-Vorlage des Ticketsystems (Q21): Kopie von termineChatTemplate im WOMS, siehe woms_chat_vorlage_gleich
WOMS_CHAT_VORLAGE,
// Hinweise im Kundenportal und Storno-Vorschläge der Vorlagen
WIDERRUF_HINWEIS,
FREIZEIT_HINWEIS,
STORNO_MUSTER_HINWEIS,
...Object.values(VORLAGEN).map((v) => v.storno),
]
return roh.map(norm)
}
// Zitate des früheren Programmstands (3a0b008, vor der Korrektur der News-Texte): sie stehen in keiner Datei des heutigen Stands und werden
// deshalb über ihren SHA-256 festgehalten (Absätze normalisiert, mit einem Leerzeichen verbunden). Quelle: `git show 3a0b008:server/config.js`
// (consentText der News) und `git show 3a0b008:public/vertragsunterlagen/funktionen-leistungsbeschreibung.html` (Abschnitt 2 und 6).
// Ist der Git-Verlauf vorhanden, vergleicht alte_fassung_gleich_git die Zitate zusätzlich mit dem Stand 3a0b008.
const ALTE_FASSUNG = 'Alte Fassung (Stand 3a0b008)'
const ALTE_STAENDE = {
haken: '2631c05fd14f38d311b5189752a3d7942734c4f8ba66f016036dc178b17d6630',
lb6: '932f542f081299c68e7e46fb3a3375641c435e83125ebf48f9d85d8b38c4843b',
preis: '7b4e2bbdd8686b8837b7e6a52e5ae0235a55b04c525b7ad50d11a26bfb2ab000',
}
const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex')
const MIN_ZITAT = 25
/**
* Zitatabsätze, die kürzer als MIN_ZITAT sind, passen in fast jeden Text und beweisen daher nichts. Sie sind einzeln benannt und werden
* von eigenen Tests streng geprüft: die vier Überschriften der Belehrung (Gleichheit mit der Funktion in 3.2 und 3.6), die Knopftexte (3.10).
*/
const KURZE_ZITATE = new Set([
'Widerrufsbelehrung', 'Widerrufsrecht', 'Folgen des Widerrufs', 'Muster-Widerrufsformular',
'Zahlungspflichtig buchen', 'Zahlungspflichtig bestätigen', 'Termin buchen', 'Termin bestätigen',
'Termin anfragen', 'Anfrage absenden', 'Vertrag widerrufen', 'Widerruf bestätigen',
])
test('paket_zitate_woertlich: jeder Zitatabsatz steht wortgleich in einer Quelle', () => {
const alle = quellen()
const bloecke = zitatBloecke(PAKET)
assert.ok(bloecke.length >= 30, `erwartet viele Zitatblöcke, gefunden ${bloecke.length}`)
const fehlend = []
const kurz = new Set()
let geprueft = 0
for (const b of bloecke) {
if (b.ueberschrift.includes(ALTE_FASSUNG) || /Alte Fassung \(Stand 3a0b008\)/.test(b.ueberschrift)) continue // alte_fassung_gepinnt
for (const absatz of b.absaetze) {
assert.ok(absatz.length > 0)
if (absatz.length < MIN_ZITAT) {
kurz.add(absatz)
continue
}
geprueft++
if (!alle.some((q) => q.includes(absatz))) fehlend.push(absatz.slice(0, 120))
}
}
assert.deepEqual(fehlend, [], 'Zitate ohne Quelle (Programm oder Dokument geändert, Paket nachziehen)')
assert.ok(geprueft >= 60, `erwartet viele geprüfte Absätze, gefunden ${geprueft}`)
// kurze Absätze nur aus der benannten Liste (sie prüfen eigene Tests streng)
assert.deepEqual([...kurz].filter((k) => !KURZE_ZITATE.has(k)), [], 'kurzes Zitat ohne eigene Prüfung')
})
test('alte_fassung_gepinnt: Zitate des früheren Programmstands sind unverändert (SHA-256)', () => {
const bloecke = zitatBloecke(PAKET).filter((b) => b.ueberschrift.includes(ALTE_FASSUNG))
assert.ok(bloecke.length >= 4, `erwartet die alten Zitate in 3.3 und Q4, gefunden ${bloecke.length}`)
const bekannt = new Set(Object.values(ALTE_STAENDE))
const gesehen = new Set()
for (const b of bloecke) {
const h = sha256(b.absaetze.join(' '))
assert.ok(bekannt.has(h), `altes Zitat ist nicht gepinnt (${h}): ${b.absaetze[0].slice(0, 80)}`)
gesehen.add(h)
}
assert.equal(gesehen.size, bekannt.size, 'alle drei alten Texte kommen im Paket vor')
})
test('alte_fassung_gleich_git: die alten Zitate entsprechen dem Stand 3a0b008 (nur mit Git-Verlauf)', (t) => {
const wurzel = fileURLToPath(new URL('..', import.meta.url))
const git = (pfad) => {
const r = spawnSync('git', ['show', `3a0b008:${pfad}`], { cwd: wurzel, encoding: 'utf8', timeout: 20000 })
return r.status === 0 ? r.stdout : null
}
const config3 = git('server/config.js')
const lb3 = git('public/vertragsunterlagen/funktionen-leistungsbeschreibung.html')
if (config3 === null || lb3 === null) {
t.skip('Git-Verlauf mit 3a0b008 nicht vorhanden')
return
}
const haken = /consentText:\s*\n?\s*'([^']+)'/.exec(config3)?.[1]
assert.ok(haken, 'consentText im alten Stand')
const lbText = norm(lb3.replace(/<style[\s\S]*?<\/style>/g, ' ').replace(/<[^>]+>/g, ''))
const bloecke = zitatBloecke(PAKET).filter((b) => b.ueberschrift.includes(ALTE_FASSUNG))
const hakenBloecke = bloecke.filter((b) => sha256(b.absaetze.join(' ')) === ALTE_STAENDE.haken)
assert.ok(hakenBloecke.length >= 2)
for (const b of hakenBloecke) assert.equal(b.absaetze.join(' '), norm(haken))
for (const b of bloecke.filter((x) => sha256(x.absaetze.join(' ')) !== ALTE_STAENDE.haken)) {
for (const absatz of b.absaetze) assert.ok(lbText.includes(absatz), `nicht im alten Stand: ${absatz.slice(0, 80)}`)
}
})
test('woms_chat_vorlage_gleich: die Kopie der Chat-Vorlage entspricht dem Ticketsystem (wenn erreichbar)', (t) => {
const pfad = '/home/webklar/apps/ticket.webklar.com/dev-termine/src/lib/funktionenView.js'
if (!existsSync(pfad)) {
t.skip('WOMS-Repository nicht erreichbar')
return
}
const quelle = readFileSync(pfad, 'utf8')
const kopf = 'Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung '
assert.ok(quelle.includes(`${kopf}\${version}: \${links.lb} · \${links.avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.`))
assert.equal(WOMS_CHAT_VORLAGE, `${kopf}<Fassung>: <Adresse der Leistungsbeschreibung> · <Adresse des AVV>. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.`)
})
test('paket_zitate_woertlich: Bestelltexte und Belehrung der Funktionen vollständig und in der Reihenfolge des Programms', () => {
const t = config.features.termine
const drei = abschnitt(3)
const teil = (nr) => {
const m = new RegExp(`^### ${nr.replace('.', '\\.')} [^\\n]*\\n([\\s\\S]*?)(?=^### |(?![\\s\\S]))`, 'm').exec(drei)
assert.ok(m, `Unterabschnitt ${nr}`)
return m[1]
}
// 3.1: die vier Haken und der Knopf, genau in dieser Reihenfolge
const absaetze31 = zitatBloecke(teil('3.1')).flatMap((b) => b.absaetze)
assert.deepEqual(absaetze31, [...t.consent.items.map((i) => norm(i.text)), norm(t.consent.knopf)])
assert.deepEqual(t.consent.items.map((i) => i.id), ['unterlagen', 'gesundheit', 'beginn', 'information'])
// 3.2: der erste Block ist die Belehrung samt Muster-Widerrufsformular in voller Länge
const erster = zitatBloecke(teil('3.2'))[0]
assert.equal(erster.absaetze.join(' '), norm(widerrufsbelehrungFunktion()))
// 3.6: die Belehrung für Gäste (mit Platzhaltern für die Betriebsangaben) ebenfalls vollständig und gleich der Funktion
assert.equal(zitatBloecke(teil('3.6'))[0].absaetze.join(' '), norm(widerrufsbelehrung(PLATZHALTER_BETRIEB)))
// 3.10: die Knopftexte sind die der Funktion knopfText; die Knöpfe der Widerrufsfunktion stehen im Widget und im Hinweise-Dokument
const paare = zitatBloecke(teil('3.10')).map((b) => b.absaetze)
const mitPreis = { preisArt: 'fest', preisCent: 5000 }
assert.deepEqual(paare, [
[knopfText(mitPreis), knopfText(mitPreis, { bestaetigen: true })],
[knopfText({ preisArt: 'keiner' }), knopfText({ preisArt: 'keiner' }, { bestaetigen: true })],
[knopfText({ buchungsart: 'anfrage' }), knopfText({ buchungsart: 'anfrage' }, { bestaetigen: true })],
['Vertrag widerrufen', 'Widerruf bestätigen'],
])
const widget = lies('public/embed/termine.js')
assert.ok(widget.includes("'Vertrag widerrufen'") && widget.includes('Widerruf bestätigen'))
assert.ok(lies('docs/rechtliches/termine-hinweise.md').includes('| Widerruf (Funktion) | Vertrag widerrufen | Widerruf bestätigen |'))
})
test('paket_zitate_woertlich: hinter jedem Zitat in Abschnitt 3 steht ein Marker ⚖ Q…', () => {
const zeilen = PAKET.split('\n')
const dreiStart = zeilen.findIndex((z) => z.startsWith('## 3. '))
const dreiEnde = zeilen.findIndex((z, i) => i > dreiStart && /^## /.test(z))
const bloecke = zitatBloecke(abschnitt(3))
assert.ok(bloecke.length >= 30)
const ohne = []
for (const b of bloecke) {
const abschnittZeilen = abschnitt(3).split('\n')
let i = b.ende + 1
while (i < abschnittZeilen.length && abschnittZeilen[i].trim() === '') i++
const folge = abschnittZeilen[i] || ''
if (!/^⚖ Q\d+/.test(folge)) ohne.push(abschnittZeilen[b.start].slice(0, 80))
}
assert.deepEqual(ohne, [])
assert.ok(dreiEnde > dreiStart)
// alle Marker nennen nur Fragen von 1 bis 26
for (const q of marker(PAKET)) assert.ok(q >= 1 && q <= 26, `Q${q} außerhalb von Q1 bis Q26`)
})
// ------------------------------------------------------------------------------------------------ Fundstellen
/** Zeilen der Tabelle in Abschnitt 5: { thema, datei, bezeichner[] }. */
function fundstellen() {
const zeilen = abschnitt(5).split('\n').filter((z) => z.startsWith('|'))
assert.match(zeilen[0], /^\| Thema \| Datei \| Funktion\/Konstante \|$/)
return zeilen.slice(2).map((z) => {
const zellen = z.replace(/^\|\s*|\s*\|$/g, '').split(' | ')
assert.equal(zellen.length, 3, z)
const datei = /^`([^`]+)`$/.exec(zellen[1])
assert.ok(datei, `Datei in Backticks: ${z}`)
const bezeichner = [...zellen[2].matchAll(/`([^`]+)`/g)].map((m) => m[1])
assert.ok(bezeichner.length > 0, `Bezeichner in Backticks: ${z}`)
return { thema: zellen[0], datei: datei[1], bezeichner }
})
}
test('paket_fundstellen: jede Datei der Tabelle existiert und enthält den genannten Bezeichner', () => {
const zeilen = fundstellen()
assert.ok(zeilen.length >= 30, `erwartet viele Fundstellen, gefunden ${zeilen.length}`)
const themen = new Set()
for (const { thema, datei, bezeichner } of zeilen) {
assert.ok(!themen.has(thema), `Thema doppelt: ${thema}`)
themen.add(thema)
assert.ok(!/^\/|\.\./.test(datei), `nur Pfade im Repository: ${datei}`)
assert.ok(existsSync(new URL(`../${datei}`, import.meta.url)), `Datei fehlt: ${datei}`)
const inhalt = lies(datei)
for (const id of bezeichner) assert.ok(inhalt.includes(id), `${datei}: „${id}“ steht nicht in der Datei`)
}
// keine Zeilennummern in der Tabelle
assert.ok(!/\.(js|mjs|md|html|sh|txt):\d+/.test(abschnitt(5)), 'Fundstellen ohne Zeilennummern')
// die Anlagen A und B (PDF) und die Quellen der Dokumente existieren
for (const datei of [
`public/rechtliches/avv-terminplaner-${F}.pdf`,
`public/rechtliches/leistungsbeschreibung-terminplaner-${F}.pdf`,
`public/rechtliches/widerrufsbelehrung-funktionen-${F}.pdf`,
'docs/rechtliches/avv-terminplaner.md',
'docs/rechtliches/leistungsbeschreibung-terminplaner.md',
'docs/rechtliches/widerrufsbelehrung-funktionen.md',
]) {
assert.ok(existsSync(new URL(`../${datei}`, import.meta.url)), `Anlage fehlt: ${datei}`)
assert.ok(PAKET.includes(`\`${datei}\``), `Paket nennt ${datei}`)
}
})
// ------------------------------------------------------------------------------------------------ Zahlen
/** `const NAME = n * STUNDE_MS | TAG_MS` oder `const NAME = n` aus einem Quelltext: { n, einheit }. */
function konstante(quelle, name) {
const m = new RegExp(`const ${name} = (\\d+)(?: \\* (STUNDE|TAG)_MS)?`).exec(quelle)
assert.ok(m, `${name} fehlt im Quelltext`)
return { n: Number(m[1]), einheit: m[2] === 'STUNDE' ? 'Stunden' : m[2] === 'TAG' ? 'Tage' : '' }
}
test('paket_zahlen_gleich_code: die Zahlen in Abschnitt 2.5 sind die des Codes', () => {
const tabelle = abschnitt(2).split('### 2.5 Zahlen')[1]
assert.ok(tabelle, 'Tabelle „2.5 Zahlen“')
const zeilen = tabelle.split('\n').filter((z) => z.startsWith('|')).slice(2)
const wert = Object.fromEntries(zeilen.map((z) => [z.split(' | ')[0].replace(/^\|\s*/, ''), z]))
assert.deepEqual(Object.keys(wert), ['149 €', '14 Tage', '30 Minuten', '48 Stunden', '90 Tage', '7 Tage', '30 Tage'])
const JOBS = lies('server/services/termine/jobs.js')
const OUTBOX = lies('server/services/termine/outbox.js')
const AVV = norm(lies('docs/rechtliches/avv-terminplaner.md'))
// 149 €: Preis des Terminplaners im Register und im Produktkatalog (14 900 Cent)
assert.equal(config.features.termine.priceLabel, '149 €')
assert.equal(config.termineSetup.amountCents, 14900)
assert.ok(wert['149 €'].includes('ohne Umsatzsteuer gemäß § 19 UStG') && wert['149 €'].includes('14900'))
assert.ok(wert['149 €'].includes(config.ustHinweis))
// 14 Tage: Widerrufsfrist
assert.equal(konstante(lies('server/services/deadlines.js'), 'WITHDRAWAL_DAYS').n, 14)
assert.ok(norm(widerrufsbelehrungFunktion()).includes('binnen vierzehn Tagen'))
// 30 Minuten: Haltezeit
assert.equal(config.termine.holdMinutes, 30)
assert.ok(AVV.includes('Bestätigungsfrist von 30 Minuten'))
// 48 Stunden: Löschung nicht bestätigter Buchungen
assert.deepEqual(konstante(JOBS, 'VERFALLEN_BEHALTEN_MS'), { n: 48, einheit: 'Stunden' })
// 90 Tage: Aufbewahrung der Termine (Vorgabe) und der Entwürfe
assert.equal(konstante(JOBS, 'AUFBEWAHRUNG_VORGABE').n, 90)
assert.equal(konstante(lies('server/services/termine/entwuerfe.js'), 'ENTWURF_TAGE').n, 90)
assert.ok(AVV.includes('die Vorgabe sind 90 Tage') && AVV.includes('löscht WEBklar 90 Tage nach der letzten Änderung'))
// 7 Tage: Versandwarteschlange nach dem Versand; Sicherungen
assert.deepEqual(konstante(OUTBOX, 'GESENDET_BEHALTEN_MS'), { n: 7, einheit: 'Tage' })
assert.ok(AVV.includes('bis zu sieben Tage erhalten'))
assert.ok(lies('public/abo-bedingungen.html').includes('die letzten sieben Tagessicherungen'))
// 30 Tage: Löschung nach dem Abschalten und abgesagter Termine
assert.equal(LOESCHFRIST_TAGE, 30)
assert.deepEqual(konstante(JOBS, 'ABGESAGT_BEHALTEN_MS'), { n: 30, einheit: 'Tage' })
assert.ok(JOBS.includes('LOESCHFRIST_TAGE * TAG_MS'))
})
// ------------------------------------------------------------------------------------------------ Ende des Abos
test('paket_konsistenz_abo_ende: LB Nr. 8, AVV Nr. 9, 6c und K22 nennen 30 Tage und die Rückkehr', () => {
const abschnittVon = (md, prefix) => {
const zeilen = md.split('\n')
const start = zeilen.findIndex((z) => z.startsWith(prefix))
assert.ok(start >= 0, prefix)
const ende = zeilen.findIndex((z, i) => i > start && /^## /.test(z))
return norm(zeilen.slice(start, ende < 0 ? undefined : ende).join('\n'))
}
const lb8 = abschnittVon(lies('docs/rechtliches/leistungsbeschreibung-terminplaner.md'), '## 8. ')
const avv9 = abschnittVon(lies('docs/rechtliches/avv-terminplaner.md'), '## 9. ')
const sechsC = norm(lies('docs/rechtliches/termine-datenschutz-6c.md'))
const k22 = mailText('server/mail-templates/K22 Terminplaner beendet.html')
const quellenAbo = { 'LB Nr. 8': lb8, 'AVV Nr. 9': avv9, '6c': sechsC, K22: k22 }
for (const [name, text] of Object.entries(quellenAbo)) {
assert.ok(/\b30 Tage\b/.test(text), `${name} nennt „30 Tage“`)
assert.ok(/wieder ein\b|Rückkehr/.test(text), `${name} nennt die Rückkehr`)
}
// die Rückkehr gilt in allen vier Texten für ein neues Wartungs-Abo
for (const [name, text] of Object.entries(quellenAbo)) assert.ok(text.includes('Wartungs-Abo'), `${name} nennt das Wartungs-Abo`)
// dieselbe Zahl wie im Code (frist.js) und im Abgleich
assert.equal(LOESCHFRIST_TAGE, 30)
assert.ok(lb8.includes('30 Tage nach dem Abschalten') && avv9.includes('30 Tage danach'))
// das Paket sagt dasselbe (Abschnitt 2.3)
const drei = norm(abschnitt(2).split('### 2.3 ')[1].split('### 2.4 ')[0])
assert.ok(drei.includes('30 Tage') && drei.includes('Rückkehr') && drei.includes('spätestens am folgenden Tag'))
})
test('paket_checkliste_livegang: Abschnitt 7.6 nennt die Schritte vor, bei und nach dem Deploy in fester Reihenfolge; Skripte, Indizes und Fristen stimmen mit dem Code überein', () => {
const sieben = abschnitt(7)
const [, ab76] = sieben.split('### 7.6 ')
assert.ok(ab76, 'Abschnitt 7.6')
const [livegang, nachher] = ab76.split('### 7.7 ')
assert.ok(nachher, 'Abschnitt 7.7')
const punkte = (text) => text.split('\n').filter((z) => z.startsWith('- [ ] '))
const l = punkte(livegang)
const folge = [
'scripts/termine-setup.mjs', // (a) Schema des Terminplaners vor dem Code-Deploy
'scripts/feature-setup.mjs', // (b) Schema der Funktionen vor dem Code-Deploy
'scripts/build-legal-pdfs.sh datenschutz', // (c) nur das PDF der Datenschutzerklärung
'ruhigen Zeit', // (d) Deploy bei wenig Verkehr
'Uploads in WOMS', // Uploads nach dem Deploy
'scripts/feature-consent-felder-leeren.mjs', // (f) mit Freigabe des Inhabers
'TERMINE_ENABLED=on', // (e) als allerletzter Schritt
]
assert.equal(l.length, folge.length, 'sieben Schritte')
folge.forEach((wort, i) => assert.ok(l[i].includes(wort), `Schritt ${i + 1} nennt „${wort}“: ${l[i].slice(0, 80)}`))
assert.ok(l[0].includes('--check') && l[1].includes('--check'), 'beide Schema-Skripte erst mit --check')
assert.ok(l[1].includes('Code-Deploy, nicht erst der erste Löschlauf'), '(b) Zeitpunkt: vor dem Code-Deploy')
assert.ok(!PAKET.includes('vor dem ersten Löschlauf der Aufbewahrungsfristen ausführen'), 'der alte Zeitpunkt steht nirgends mehr')
assert.ok(l[2].includes('nicht mit `all`'), '(c) nicht all')
assert.ok(l[3].includes('Fassung der Gästetexte, die der Server beim Speichern hat'), '(d) der Nachweis speichert die Fassung des Servers')
assert.ok(l[5].includes('nur mit Freigabe des Inhabers') && l[5].includes('Probelauf') && l[5].includes('--dry-run') && l[5].includes('--apply'), '(f) Probelauf, dann --apply, mit Freigabe')
assert.ok(l[6].startsWith('- [ ] Als allerletzten Schritt `TERMINE_ENABLED=on`'), '(e) TERMINE_ENABLED=on zuletzt')
assert.ok(sieben.indexOf('TERMINE_ENABLED=on') > sieben.indexOf('scripts/feature-consent-felder-leeren.mjs'), 'on nach allen anderen Schritten')
assert.equal(sieben.split('TERMINE_ENABLED=on').length - 1, 1, 'on wird nur an dieser Stelle gesetzt')
// (a) die genannten Spalten und Indizes stehen im Schema; (b)/(f) die Skripte gibt es und sagen dasselbe wie die Checkliste
const schema = lies('server/services/termine/schema.js')
assert.match(schema, /str\('fragebogen', 20000\)/)
for (const index of ['idx_status_ends', 'idx_status_updated', 'idx_booking']) {
assert.ok(schema.includes(`'${index}'`), `${index} im Schema`)
assert.ok(l[0].includes(`\`${index}\``), `Schritt 1 nennt ${index}`)
}
assert.ok(l[0].includes('`fragebogen`') && l[0].includes('`termineConfig`') && l[0].includes('`termineBookings`') && l[0].includes('`termineOutbox`'))
assert.match(lies('scripts/feature-setup.mjs'), /Vor dem Code-Deploy ausfuehren/)
assert.match(lies('scripts/termine-setup.mjs'), /--check/)
const pdfs = lies('scripts/build-legal-pdfs.sh')
assert.ok(pdfs.includes('\ndatenschutz)\n') && pdfs.includes('\nall)\n'), 'build-legal-pdfs.sh kennt datenschutz und all')
const leeren = lies('scripts/feature-consent-felder-leeren.mjs')
assert.ok(leeren.includes('--dry-run') && leeren.includes('--apply') && leeren.includes('Standard ist der Probelauf'))
// (g) Kunden ohne Abo: der Löschlauf erfasst sie heute nicht, die Datenschutzerklärung nennt die Frist; die erste Frist läuft Ende 2029 ab
const g = punkte(nachher)
assert.equal(g.length, 1)
assert.ok(g[0].includes('31.12.2029') && g[0].includes('`findDueDeletions`') && g[0].includes('ohne Abo'))
assert.match(lies('server/services/retention.js'), /Kunden ohne Abo, zum Beispiel nur mit News, sind nie faellig/, 'steht das nicht mehr im Code, ist die Löschung umgesetzt: Punkt 7.7 und Q23 anpassen')
assert.ok(mailText('public/datenschutz.html').includes('Bestellen Sie ohne Abo (zum Beispiel nur die News-Pinnwand), löschen wir diese Angaben spätestens drei Jahre nach Ablauf des Jahres der Bestellung.'))
assert.ok(abschnitt(4).includes('Für Bestellungen ohne Abo (nur News) nennt die Erklärung eine eigene Frist') && abschnitt(4).includes('(31.12.2029)'))
// (h) K23 verspricht „unverändert da“; der Löschlauf löscht auch bei abgeschaltetem Terminplaner Termine nach ihren Fristen
const vier = punkte(abschnitt(7).split('### 7.4 ')[1].split('### 7.5 ')[0])
assert.ok(vier.some((z) => z.includes('K23') && z.includes('unverändert da') && z.includes('abschwächen')), '(h) K23 in 7.4')
assert.ok(mailText('server/mail-templates/K23 Terminplaner laeuft wieder.html').includes('Deine Termine und Einstellungen sind unverändert da'))
assert.match(lies('server/services/termine/jobs.js'), /portalFeatures featureKey termine, active oder disabled/)
// 7.5 verweist auf 7.6
assert.ok(abschnitt(7).split('### 7.5 ')[1].split('### 7.6 ')[0].includes('Abschnitt 7.6'))
})
test('paket_ausnahme_einrichtungs_ticket_und_archiv: Q4, Q9 und Abschnitt 2.3 nennen das archivierte News-Dokument und die Ausnahme der Löschung', () => {
const alle = abschnitt(4)
const q4 = alle.split('### Q4: ')[1].split('### Q5: ')[0]
assert.ok(q4.includes('`public/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html`'))
assert.ok(existsSync(new URL('../public/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html', import.meta.url)))
assert.ok(q4.includes('unveränderte Kopie des Standes 3a0b008') && q4.includes('Prüfsumme'))
const q9 = alle.split('### Q9: ')[1].split('### Q10: ')[0]
assert.ok(q9.includes('(g) Das Einrichtungs-Ticket') && q9.includes('Leistungsbeschreibung Nr. 8 Abs. 3 und AVV Nr. 9 Abs. 6'))
assert.ok(abschnitt(2).split('### 2.3 ')[1].split('### 2.4 ')[0].includes('das Einrichtungs-Ticket des Teams bleibt als Arbeitsnachweis'))
})
// ------------------------------------------------------------------------------------------------ Fragen Q1 bis Q26 (Task F15b)
const FRAGE_TEILE = ['**Frage.**', '**Fundstelle im Text.**', '**Unsere Annahme.**', '**Alternative.**']
/** Fragen aus Abschnitt 4: [{ nr, titel, text, teile: { frage, fundstelle, annahme, alternative } }] in der Reihenfolge des Pakets. */
function fragen() {
const text = abschnitt(4)
const treffer = [...text.matchAll(/^### Q(\d+): (.+)$/gm)]
return treffer.map((m, i) => {
const ende = i + 1 < treffer.length ? treffer[i + 1].index : text.length
const rumpf = text.slice(m.index, ende)
const stellen = FRAGE_TEILE.map((marke) => rumpf.indexOf(marke))
const teile = {}
;['frage', 'fundstelle', 'annahme', 'alternative'].forEach((name, k) => {
if (stellen[k] < 0) return
const bis = k + 1 < stellen.length ? stellen[k + 1] : rumpf.length
teile[name] = rumpf.slice(stellen[k] + FRAGE_TEILE[k].length, bis).trim()
})
return { nr: Number(m[1]), titel: m[2], text: rumpf, teile, stellen }
})
}
test('paket_marker_und_fragen: Q1 bis Q26 lückenlos, je vier Teile, jede Frage verwiesen, jeder Marker hat seine Frage', () => {
const alle = fragen()
assert.deepEqual(alle.map((f) => f.nr), Array.from({ length: 26 }, (_, i) => i + 1), 'lückenlos Q1 bis Q26 in dieser Reihenfolge')
assert.ok(!abschnitt(4).includes('Task F15b'), 'der Platzhalter von F15a ist ersetzt')
for (const f of alle) {
assert.ok(f.titel.trim().length >= 5, `Q${f.nr}: Titel`)
assert.ok(f.stellen.every((p) => p >= 0) && f.stellen.every((p, k) => k === 0 || p > f.stellen[k - 1]), `Q${f.nr}: vier Teile in der Reihenfolge Frage, Fundstelle, Annahme, Alternative`)
for (const [name, inhalt] of Object.entries(f.teile)) assert.ok(inhalt.length >= 20, `Q${f.nr}: ${name} ist leer`)
assert.ok(!/Task F15|TODO|\?\?\?/.test(f.text), `Q${f.nr}: kein Platzhalter`)
}
// jeder Marker nennt eine vorhandene Frage, und jede Frage wird mindestens einmal verwiesen
const verwiesen = new Set(marker(PAKET))
for (const q of verwiesen) assert.ok(q >= 1 && q <= 26 && alle.some((f) => f.nr === q), `⚖ Q${q} ohne ### Q${q}`)
for (const f of alle) assert.ok(verwiesen.has(f.nr), `Q${f.nr} wird nirgends mit ⚖ verwiesen`)
// Q4 (D7): News-Altkunden
const q4 = alle[3]
for (const wort of ['News', '§ 356 Abs. 3', 'Altkunden']) assert.ok(q4.text.includes(wort), `Q4 nennt „${wort}“`)
assert.ok(q4.teile.frage.includes('[Anzahl'), 'Q4: die Zahl der Fälle liefert der Inhaber')
})
/** Zeilen der Zuordnungstabelle in Abschnitt 6.3: Kennung -> { punkt, wo }. */
function zuordnung() {
const text = abschnitt(6).split('### 6.3 ')[1]
assert.ok(text, '6.3')
const zeilen = text.split('\n').filter((z) => /^\| [HABC]\d /.test(z))
const tabelle = {}
for (const z of zeilen) {
const zellen = z.replace(/^\|\s*|\s*\|$/g, '').split(' | ')
assert.equal(zellen.length, 3, z)
assert.ok(!(zellen[0] in tabelle), `Kennung doppelt: ${zellen[0]}`)
tabelle[zellen[0]] = { punkt: zellen[1], wo: zellen[2] }
}
return tabelle
}
test('paket_offene_punkte_abgedeckt: jeder Punkt der entfernten Listen steht in einer Frage oder hat in Abschnitt 6 einen Grund', () => {
const erwartet = [
...[1, 2, 3, 4].map((n) => `H${n}`),
...[1, 2, 3, 4, 5, 6].map((n) => `A${n}`),
...[1, 2, 3, 4, 5, 6].map((n) => `B${n}`),
...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `C${n}`),
]
const tabelle = zuordnung()
assert.deepEqual(Object.keys(tabelle), erwartet, 'jede Kennung genau einmal, in dieser Reihenfolge')
const alle = fragen()
for (const kennung of erwartet) {
const { punkt, wo } = tabelle[kennung]
assert.ok(punkt.length >= 10, `${kennung}: Punkt benannt`)
const frage = /^Q(\d+)$/.exec(wo)
if (frage) {
// Zuordnung zu einer Frage: die Kennung steht in der „Fundstelle im Text“ genau dieser Frage
const f = alle.find((x) => x.nr === Number(frage[1]))
assert.ok(f, `${kennung}: Frage ${wo} fehlt`)
assert.ok(new RegExp(`\\b${kennung}\\b`).test(f.teile.fundstelle), `${kennung} steht nicht in der Fundstelle von ${wo}`)
} else {
// sonst nur mit Begründung (hier) und nur die drei dokumentierten Fälle
assert.ok(wo.startsWith('hier: ') && wo.length > 40, `${kennung}: Grund fehlt`)
assert.ok(['B3', 'C5', 'C6'].includes(kennung), `${kennung} muss in einer Frage stehen`)
for (const f of alle) assert.ok(!new RegExp(`\\b${kennung}\\b`).test(f.teile.fundstelle), `${kennung} steht schon in Q${f.nr}`)
}
}
// jede Kennung in den Fragen ist in der Tabelle bekannt
for (const f of alle) {
for (const m of f.teile.fundstelle.matchAll(/\b([HABC]\d)\b/g)) assert.ok(erwartet.includes(m[1]), `Q${f.nr}: unbekannte Kennung ${m[1]}`)
}
})
test('paket_offene_punkte_abgedeckt: die Hinweise der Reviews (Kanzlei-Quellen) stehen in der passenden Frage', () => {
const alle = fragen()
const frage = (n) => alle[n - 1].text
const hat = (n, ...woerter) => { for (const w of woerter) assert.ok(frage(n).includes(w), `Q${n} nennt „${w}“`) }
hat(1, 'Verbraucher', 'Unternehmer')
hat(2, 'Zahlungseingang', 'K15')
hat(3, '§ 356 Abs. 5 Nr. 2', '§ 357a Abs. 2', 'dauerhaften Datenträger')
hat(5, 'Gesamtpreis', 'Arbeitsblatt', 'Stundenaufwand')
hat(6, 'abo_ende_vor_einrichtung', 'abo_ende', 'Nr. 8 Abs. 4', '§ 307')
hat(7, 'unlockedByPurchase', 'ausgenommen', 'Pilot', 'Kulanz')
hat(8, '§ 309 Nr. 7', 'Höchstbetrag', 'Einstellung')
hat(9, 'disabledAt', 'Terminanfragen', 'Rückkehr', 'Sicherung')
hat(10, 'unverzüglich', 'Absagegrund', 'Zählungen', 'Drittländer', 'Kalender')
hat(11, 'Erstattung', 'ablehnt', 'Art. 9')
hat(12, 'Lieferkosten', 'Gesetzlichkeitsfiktion')
hat(14, '§ 312j Abs. 3')
hat(16, 'G02', '§ 307', 'kostenlosen')
hat(19, '§ 19 UStG', '· 149 €')
hat(20, 'vereinbarten Fassung', 'Art. 246a § 1')
hat(21, 'Textform', 'K16', 'Art. 28 Abs. 9')
hat(22, 'BFSG')
hat(23, 'drei Jahre', 'Browserkennung')
hat(24, '90 Tage')
hat(25, 'Mindestlaufzeit')
hat(26, 'Hash', 'Archiv', 'Haltezeit')
})
test('paket_ohne_interne_verweise: nichts, was die Kanzlei nicht auflösen kann', () => {
// Entscheidungsnummern der Spezifikation, Aufgabenkürzel und Projektphasen sind für Außenstehende unverständlich
assert.deepEqual(PAKET.match(/\bD[1-8]\b/g), null, 'Entscheidungsnummer D1 bis D8')
assert.deepEqual(PAKET.match(/\bTP[0-9]\b|\bTask F\d+|Spezifikation|Ledger|\bR\d{2,3}\b/g), null, 'internes Kürzel')
// alle Q-Verweise im Fließtext (Q3, Q20) zeigen auf vorhandene Fragen
for (const m of PAKET.matchAll(/\bQ(\d+)\b/g)) assert.ok(Number(m[1]) >= 1 && Number(m[1]) <= 26, `Q${m[1]}`)
})
+59 -11
View File
@@ -18,13 +18,13 @@ after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { createDocument, deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { createDocument, deleteDocument, dokumenteUrl } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { loadTemplate } = await import('../server/services/mailTemplates.js')
const { icsTermin } = await import('../server/services/termine/ics.js')
const O = await import('../server/services/termine/outbox.js')
const { einreihen, sendeEintrag, verarbeite, warteAufVersand, zustellFehler, raeumeAuf, beendeFestgefahrene, verwerfeWartende, pausiertBis, resetOutboxForTests } = O
const { MAIL_VORRANG, MAIL_NACHRANGIG, mailKlasse, pruefePlatz, mailBudget, verworfeneG01 } = O
const { MAIL_VORRANG, MAIL_NACHRANGIG, AUSSCHLUSS_MAX, mailKlasse, pruefePlatz, mailBudget, verworfeneG01 } = O
after(() => setMailTransportForTests(null))
const MIN = 60000
@@ -308,10 +308,10 @@ test('Fehlversuche verbrauchen kein Stundenbudget (auch nicht Anmelde- oder Verb
/** Eintrag einer bestimmten Funktion und Art (Vorlage bleibt G01: die Warteschlange prueft Art und Vorlage nicht gegeneinander). */
const von = (featureId, art, nummer, extra = {}) => eintrag({ featureId, art, bookingId: `b_${featureId}_${art}_${nummer}`, ...extra })
test('mailKlasse: G01/G02/G08 vorrang, G05/K20/K21 nachrangig, alle anderen normal', () => {
test('mailKlasse: G01/G02/G08 vorrang, G05/K20/K21 nachrangig, alle anderen normal (auch die Webshop-Mails G09–G15)', () => {
assert.deepEqual(MAIL_VORRANG, ['G01', 'G02', 'G08'])
assert.deepEqual(MAIL_NACHRANGIG, ['G05', 'K20', 'K21'])
const erwartet = { G01: 'vorrang', G02: 'vorrang', G03: 'normal', G04: 'normal', G05: 'nachrangig', G06: 'normal', G07: 'normal', G08: 'vorrang', K20: 'nachrangig', K21: 'nachrangig' }
const erwartet = { G01: 'vorrang', G02: 'vorrang', G03: 'normal', G04: 'normal', G05: 'nachrangig', G06: 'normal', G07: 'normal', G08: 'vorrang', G09: 'normal', G10: 'normal', G11: 'normal', G12: 'normal', G13: 'normal', G14: 'normal', G15: 'normal', K20: 'nachrangig', K21: 'nachrangig' }
assert.deepEqual(Object.fromEntries(Object.keys(erwartet).map((art) => [art, mailKlasse(art)])), erwartet)
assert.equal(mailKlasse('unbekannt'), 'normal')
})
@@ -509,15 +509,21 @@ test('Reserve: mit sehr kleinem Stundenbudget darf mindestens eine nachrangige M
// ---------------------------------------------------------------------------------------------------------------------
const listenAbfragen = () => aw.listCalls('termineOutbox')
// Appwrite 1.8.1: notEqual nimmt genau einen Wert, mehrere notEqual werden geschnitten. Die normale Klasse sind darum sechs
// notEqual auf art, die Ausschlussliste je Funktion eine notEqual auf featureId.
const abfrageKlasse = ({ queries }) => {
const art = queries.find((q) => q.attribute === 'art')
if (art.method === 'notEqual') return 'normal'
return art.values.includes('G01') ? 'vorrang' : 'nachrangig'
}
const abfrageKlassen = () => listenAbfragen().map(abfrageKlasse)
const ausschluss = ({ queries }) => queries.find((q) => q.method === 'notEqual' && q.attribute === 'featureId')?.values ?? null
const ausschluss = ({ queries }) => {
const ids = queries.filter((q) => q.method === 'notEqual' && q.attribute === 'featureId').map((q) => q.values[0])
return ids.length ? ids : null
}
const begrenzteLeseabfragen = (abfragen) => {
for (const abfrage of abfragen) {
assert.ok(abfrage.queries.filter((q) => q.method === 'notEqual').every((q) => q.values.length === 1), `notEqual mit genau einem Wert (Appwrite 1.8.1): ${JSON.stringify(abfrage.queries)}`)
const limit = abfrage.queries.find((q) => q.method === 'limit')
assert.ok(limit && limit.values[0] <= 100, `limit höchstens 100: ${JSON.stringify(abfrage.queries)}`)
assert.ok(!abfrage.queries.some((q) => q.method === 'cursorAfter' || (q.method === 'orderAsc' && q.attribute === '$id')), 'kein $id-Seitenlauf')
@@ -606,6 +612,31 @@ test('Reihenfolge: Vorrang vor normal vor nachrangig, innerhalb einer Klasse die
}
})
// G5 (2026-10-05): auf der echten Appwrite 1.8.1 lehnt notEqual mit Liste die Abfrage ab (HTTP 400). Die normale Klasse warf dann bei jedem Lauf,
// die nachrangige (G05, K20, K21) kam nie dran. Der Nachbau lehnt es jetzt genauso ab.
test('Live-Appwrite: ein Lauf mit fälligen Einträgen aller drei Klassen bedient Vorrang, normal UND nachrangig (G05, K20, K21)', async () => {
const arten = ['G02', 'G08', 'G03', 'K17', 'G05', 'K20', 'K21']
const docs = []
for (const [i, art] of arten.entries()) {
docs.push(await legeAn({ art, bookingId: `b_${art}`, an: `${art.toLowerCase()}@example.com`, naechsterVersuch: plus(-(10 - i) * MIN).toISOString() }))
}
aw.resetListCalls()
const ergebnis = await verarbeite(JETZT)
assert.deepEqual(ergebnis, { faellig: 7, gesendet: 7 })
for (const doc of docs) assert.equal((await lies(doc.$id)).status, 'gesendet', doc.art)
assert.deepEqual(empfaenger(), ['g02@example.com', 'g08@example.com', 'g03@example.com', 'k17@example.com', 'g05@example.com', 'k20@example.com', 'k21@example.com'])
assert.deepEqual(abfrageKlassen(), ['vorrang', 'normal', 'nachrangig'])
begrenzteLeseabfragen(listenAbfragen())
// die normale Klasse: sechs einzelne notEqual auf art, zusammen alle Arten der anderen Klassen
const normal = listenAbfragen().find((a) => abfrageKlasse(a) === 'normal')
const ausgeschlossen = normal.queries.filter((q) => q.method === 'notEqual' && q.attribute === 'art')
assert.ok(ausgeschlossen.every((q) => q.values.length === 1))
assert.deepEqual(ausgeschlossen.map((q) => q.values[0]).sort(), [...MAIL_VORRANG, ...MAIL_NACHRANGIG].sort())
// vorrang und nachrangig: equal mit Liste ist bei Appwrite erlaubt
const nachrangig = listenAbfragen().find((a) => abfrageKlasse(a) === 'nachrangig')
assert.deepEqual(nachrangig.queries.find((q) => q.attribute === 'art'), { method: 'equal', attribute: 'art', values: [...MAIL_NACHRANGIG] })
})
test('kein Verhungern: 150 fällige G02 der Funktion A (Kontingent 40) vor einem G02 der Funktion B ⇒ ein Lauf sendet 40 von A und die Mail von B', async () => {
for (let i = 0; i < 150; i++) await legeAn({ featureId: 'f_A', art: 'G02', bookingId: `b_a${i}`, naechsterVersuch: plus(-3600000 + i * 1000).toISOString() })
const b = await legeAn({ featureId: 'f_B', art: 'G02', bookingId: 'b_b', an: 'b@example.com', naechsterVersuch: plus(-1000).toISOString() })
@@ -633,21 +664,38 @@ test('Runden: höchstens 5 Abfragen je Klasse; mailListe wird bei jedem Aufruf g
assert.deepEqual(await verarbeite(JETZT), { faellig: 2, gesendet: 2 })
})
test('Ausschlussliste: höchstens 90 Funktionen je Abfrage, auch wenn mehr blockiert sind', async () => {
// Appwrite 1.8.1 (M4): ab etwa 45 featureId-notEqual in dieser Abfrageform antwortet der nginx mit 414 (URL zu lang); 30 lassen Luft
test('Ausschlussliste: höchstens AUSSCHLUSS_MAX (30) Funktionen je Abfrage, auch wenn mehr blockiert sind; die URL bleibt unter 6000 Zeichen', async () => {
assert.equal(AUSSCHLUSS_MAX, 30)
config.termine.mailsProFunktion = 1
// 36 Zeichen lange Funktions-IDs: so lang sind die Dokument-IDs in Produktion
const feature = (i) => `f-${String(i).padStart(34, '0')}`
assert.equal(feature(7).length, 36)
for (let i = 0; i < 100; i++) {
for (const teil of ['a', 'b']) {
await legeAn({ featureId: `f_${i}`, art: 'G02', bookingId: `b_${i}${teil}`, naechsterVersuch: plus(-3600000 + (2 * i + (teil === 'a' ? 0 : 1)) * 1000).toISOString() })
await legeAn({ featureId: feature(i), art: 'G02', bookingId: `b_${i}${teil}`, naechsterVersuch: plus(-3600000 + (2 * i + (teil === 'a' ? 0 : 1)) * 1000).toISOString() })
}
}
aw.resetListCalls()
assert.deepEqual(await verarbeite(JETZT), { faellig: 210, gesendet: 100 })
// Runde 1: Seite 1 (f0 bis f49) sendet 50, blockiert 50. Runde 2 (die ersten 30 ausgeschlossen): f30b bis f49b sind wieder dabei, dahinter
// f50 bis f89 (40 gesendet). Runde 3: 60 + 20 Eintraege, die letzten 10 gehen raus. Die Seite ist dann nicht mehr voll.
assert.deepEqual(await verarbeite(JETZT), { faellig: 280, gesendet: 100 })
const abfragen = listenAbfragen()
begrenzteLeseabfragen(abfragen)
const vorrang = abfragen.filter((a) => abfrageKlasse(a) === 'vorrang')
assert.deepEqual(vorrang.map((a) => ausschluss(a)?.length ?? 0), [0, 50, 90], 'Runde 3: 100 blockierte Funktionen, 90 davon in der Liste')
for (const abfrage of abfragen) assert.ok((ausschluss(abfrage)?.length ?? 0) <= 90)
assert.ok(abfragen.filter((a) => abfrageKlasse(a) !== 'vorrang').every((a) => ausschluss(a).length === 90), 'blockierte Funktionen gelten für alle Klassen')
assert.deepEqual(vorrang.map((a) => ausschluss(a)?.length ?? 0), [0, 30, 30], '100 Funktionen blockiert, aber nur 30 in der Liste')
assert.deepEqual(ausschluss(vorrang[1]), Array.from({ length: 30 }, (_, i) => feature(i)), 'die zuerst blockierten')
for (const abfrage of abfragen) assert.ok((ausschluss(abfrage)?.length ?? 0) <= AUSSCHLUSS_MAX)
assert.ok(abfragen.filter((a) => abfrageKlasse(a) !== 'vorrang').every((a) => ausschluss(a).length === AUSSCHLUSS_MAX), 'blockierte Funktionen gelten für alle Klassen')
assert.equal(alle().filter((d) => d.status === 'gesendet').length, 100)
// Schlimmster Fall: die normale Klasse (6 notEqual auf art) mit voller Ausschlussliste. Dieselbe Serialisierung wie die Anfrage an Appwrite.
const normal = abfragen.find((a) => abfrageKlasse(a) === 'normal')
assert.equal(normal.queries.filter((q) => q.method === 'notEqual' && q.attribute === 'art').length, 6)
assert.equal(normal.queries.filter((q) => q.method === 'notEqual' && q.attribute === 'featureId').length, 30)
const laengen = abfragen.map((a) => dokumenteUrl(col(), a.queries).length)
assert.ok(Math.max(...laengen) < 6000, `längste Abfrage-URL ${Math.max(...laengen)} Zeichen`)
assert.equal(laengen[abfragen.indexOf(normal)], Math.max(...laengen), 'die normale Klasse ist der längste Fall')
})
test('Reserve erreicht: keine Abfrage der nachrangigen Klasse mehr; Vorrang und normal kommen trotzdem dran', async () => {
+11 -1
View File
@@ -135,7 +135,17 @@ const HASH_2026_10_02 = '920633e2e23c9c98aded0c1509db5cac1465058a025aeddc76a9a12
// SHA-256 der Archivdatei der aktuellen Fassung: ändert sich ein Gästetext, ohne dass die Fassung (RECHTSTEXTE_VERSION) ein neues Datum
// und diese Datei eine neue Archivdatei bekommt, schlägt der Test an. Bei einer neuen Fassung: neue Datei, neues Datum, neuer Pin.
const HASH_AKTUELL = { '2026-10-05': '6bcad4c4a02ea5e39b3ec690d40617fdfb3cb1290aadd3304f0ecabab14c4a59' }
const HASH_AKTUELL = { '2026-10-05': '830bc7364afc852755ca66d2213866160812f35ba7221f59198acf37588a8ada' }
test('archiv_storno_regel: die Fassung 2026-10-05 nennt den Storno-Haken nur für Leistungen mit Preis, die eingefrorene Fassung 2026-10-02 die alte Regel', () => {
const neu = lies(archivPfad())
assert.ok(neu.includes('### Stornobedingungen (Pflicht bei einer Leistung mit Preis, wenn der Betrieb Stornobedingungen hat; bei kostenlosen Leistungen fragt der Terminplaner nicht danach;'))
assert.ok(!neu.includes('### Stornobedingungen (Pflicht, wenn der Betrieb Stornobedingungen hat;'))
const alt = lies(`${ARCHIV_DIR}/termine-gaestetexte-2026-10-02.md`)
assert.ok(alt.includes('### Stornobedingungen (Pflicht, wenn der Betrieb Stornobedingungen hat;'), 'die Altfassung beschreibt die alte Regel')
assert.ok(lies('docs/rechtliches/termine-hinweise.md').includes('| Stornobedingungen | der Betrieb hat Stornobedingungen hinterlegt und die gebuchte Leistung hat einen Preis'))
assert.ok(lies(`${ARCHIV_DIR}/README.md`).includes('Pflicht ist er nur noch bei einer Leistung mit Preis'))
})
test('textstand_pin: Hash der aktuellen Archivdatei ist festgenagelt (Textänderung ohne neues Datum fällt auf)', () => {
assert.ok(Object.hasOwn(HASH_AKTUELL, RECHTSTEXTE_VERSION), `kein Hash-Pin für die Fassung ${RECHTSTEXTE_VERSION}`)
+31 -1
View File
@@ -1,7 +1,11 @@
import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { config } from '../server/config.js'
import { termineAus, termineFuerKunde, termineModus } from '../server/services/termine/schalter.js'
import * as schalter from '../server/services/termine/schalter.js'
const { termineAus, termineFuerKunde, termineModus } = schalter
// Namensraum für termineTextformNoetig: fehlt der Export, scheitert nur sein Test
const termineTextformNoetig = (email) => schalter.termineTextformNoetig(email)
const TERMINE_ENV = [
'TERMINE_ENABLED',
@@ -162,3 +166,29 @@ test('termineFuerKunde: on gilt fuer alle ohne E-Mail-Filter', () => {
assert.equal(termineFuerKunde('jemand@example.com'), true)
assert.equal(termineFuerKunde(undefined), true)
})
test('termineTextformNoetig: nur im Modus on und nur außerhalb der Pilotliste; „*“ und fehlende Adresse befreien nicht', () => {
for (const mode of ['off', 'pilot', undefined, '', 'banane']) {
config.termine.mode = mode
config.termine.pilotEmails = []
assert.equal(termineTextformNoetig('jemand@example.com'), false, `mode=${JSON.stringify(mode)}`)
config.termine.pilotEmails = ['*']
assert.equal(termineTextformNoetig('jemand@example.com'), false, `mode=${JSON.stringify(mode)} mit Stern`)
}
config.termine.mode = 'on'
config.termine.pilotEmails = []
assert.equal(termineTextformNoetig('jemand@example.com'), true)
config.termine.pilotEmails = ['kenso.gri@gmail.com']
assert.equal(termineTextformNoetig('jemand@example.com'), true)
assert.equal(termineTextformNoetig('KENSO.gri@gmail.com '), false, 'Pilotadresse, Schreibweise egal')
assert.equal(termineTextformNoetig('kenso.gri@gmail.com.evil.de'), true)
config.termine.pilotEmails = ['*']
assert.equal(termineTextformNoetig('jemand@example.com'), true, 'der Stern zählt in on nicht als Pilotadresse')
assert.equal(termineTextformNoetig('*'), true)
config.termine.pilotEmails = ['*', 'kenso.gri@gmail.com']
assert.equal(termineTextformNoetig('kenso.gri@gmail.com'), false)
for (const email of [undefined, null, '', ' ']) assert.equal(termineTextformNoetig(email), true, `ohne Adresse: ${JSON.stringify(email)}`)
// wirkt sofort wie die übrigen Schalter
config.termine.mode = 'pilot'
assert.equal(termineTextformNoetig('jemand@example.com'), false)
})
+23 -1
View File
@@ -10,7 +10,7 @@ import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// stehen Kopfzeilen, der Hinweis und der Verweis im Ticket. websiteProjects, workorders und customers legt das Setup-Skript nicht
// an: ohne Schema im Nachbau.
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), termineConfig: schemaKeys('termineConfig') },
schema: { portalFeatures: schemaKeys('portalFeatures'), termineConfig: schemaKeys('termineConfig'), termineBookings: schemaKeys('termineBookings') },
datetimes: TERMINE_DATETIMES,
})
after(() => aw.close())
@@ -191,6 +191,28 @@ test('termineTicketDetails: Namen mit Markup bleiben Text, halbe Zeichenpaare we
assert.ok((await termineTicketDetails(ctxFuer(doc, projekt, null))).includes('Kunde: c1'))
})
test('Ticket als bleibender Arbeitsnachweis (AVV Nr. 9 Abs. 6): Betriebsangaben, Leistungsnamen, nur die ersten acht Personen, Wünsche des Kunden - und nichts von den Gästen', async () => {
const e = einstellung()
e.ressourcen = Array.from({ length: 10 }, (_, i) => ({
id: `r_tk${String(i).padStart(5, '0')}`, typ: 'person', name: `Mitarbeiter ${i + 1}`, aktiv: true, plan: { 1: [['09:00', '12:00']] }, feedToken: neuerFeedToken(),
}))
const fb = { einbau: { modus: 'inline', knopfText: '', stelle: 'Startseite', gestaltung: '', bemerkung: 'Bitte vor dem 20.10.' } }
const { doc } = await bestelle({ e, fb })
// eine Buchung dieses Terminplaners mit allen Angaben des Gastes: nichts davon darf ins Ticket gelangen
aw.seed('termineBookings', {
$id: 'b_tk1', featureId: doc.$id, customerId: 'c1', status: 'bestaetigt', leistungName: e.leistungen[0].name, ressourceName: 'Mitarbeiter 1',
gastName: 'Erika Gastgeberin', gastEmail: 'erika.gast@example.test', gastTelefon: '0171 5550000', gastNachricht: 'Bitte ohne Föhn', gastZusatz: '{"allergie":"Nuss"}', absageGrund: 'Krank',
})
const text = await termineTicketDetails(ctxFuer(doc))
assert.ok(text.includes('Projekt: Salon Haarlinie') && text.includes('Kunde: Kunde Eins'), 'Betriebsangaben')
assert.ok(text.includes(BETRIEB.telefon), 'Telefon des Betriebs (Ausweichtext im Einbau-Code)')
for (const l of e.leistungen) assert.ok(text.includes(l.name), `Leistung ${l.name}`)
for (let i = 1; i <= 8; i += 1) assert.ok(text.includes(`Mitarbeiter ${i}`), `Mitarbeiter ${i}`)
assert.ok(!text.includes('Mitarbeiter 9') && !text.includes('Mitarbeiter 10') && text.includes('und 2 weitere'), 'nur die ersten acht Namen')
assert.ok(text.includes('Bemerkung: Bitte vor dem 20.10.') && text.includes('Stelle: Startseite'), 'Wünsche des Kunden zum Einbau')
for (const gast of ['Erika', 'Gastgeberin', 'erika.gast', '0171', 'Föhn', 'Nuss', 'Krank']) assert.ok(!text.includes(gast), `keine Gastdaten: ${gast}`)
})
test('termineTicketDetails: Gesundheit (Physiotherapie) und Maximalbefuellung: enthaelt Physio und bleibt hoechstens 7000 Zeichen', async () => {
const e = maximalEinstellung()
const meldungen = gesundheitsMeldungen(e)
+38 -1
View File
@@ -625,7 +625,10 @@ test('avv_inhalt: Abschluss, Zwecke ohne Eigennutz, Datenarten, Weisungen, Daten
assert.ok(vorbemerkung.includes('Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person.'))
assert.ok(vorbemerkung.includes('Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte und die Widerrufsbelehrung in dieser Fassung als PDF enthält'))
assert.ok(vorbemerkung.includes('Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform'))
assert.ok(vorbemerkung.includes('Der Kunde erhält die E-Mail nach Absatz 1 anschließend.'))
// T1: In der Textform-Aktivierung trägt K16 nur Leistungsbeschreibung und AVV als PDF (ohne Widerrufsbelehrung, ohne Links): der Text verspricht nicht die E-Mail nach Absatz 1
assert.ok(vorbemerkung.includes('Der Kunde erhält anschließend eine E-Mail mit diesen beiden Texten als PDF.'))
assert.ok(!vorbemerkung.includes('E-Mail nach Absatz 1 anschließend'), 'der Satz verwies auf die Mail mit Belehrung und Links')
assert.match(lies('server/routes/admin/funktionenAdmin.js'), /featureLegalAttachments\(key, \{ ohne: \['widerruf'\] \}\)/, 'K16 der Textform-Aktivierung (Terminplaner und Webshop): nur Leistungsbeschreibung und AVV')
assert.ok(vorbemerkung.includes('Für den Terminplaner geht er Anlage 1 vor'))
assert.ok(vorbemerkung.includes('Anlage 1 der Abo-Bedingungen'))
@@ -673,6 +676,37 @@ test('avv_inhalt: Abschluss, Zwecke ohne Eigennutz, Datenarten, Weisungen, Daten
assert.ok(nr12.includes('Im Übrigen gilt § 16 der Abo-Bedingungen.'))
})
test('avv_ausnahmen_loeschung: Einrichtungs-Ticket bleibt (Nr. 9 Abs. 6, Anlage, LB Nr. 8 Abs. 3); fehlgeschlagene E-Mails behalten Adresse und Inhalt bis Nr. 9 Abs. 4 (Nr. 8 Abs. 2)', () => {
// T2: das Einrichtungs-Ticket (workorders.details = Kurzfassung der Einstellungen, ticketDetails.js) wird beim Löschlauf nicht angefasst
const ticket = 'Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen'
const nr9 = avvNr(9)
const abs6 = nr9.slice(nr9.indexOf('(6) '), nr9.indexOf('(7) '))
assert.ok(abs6.includes(ticket))
assert.ok(abs6.includes('(zum Beispiel Betriebsangaben, Namen der Leistungen, die ersten acht Namen der Personen oder Plätze und die Wünsche des Kunden zum Einbau) und enthält keine Daten von Gästen; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6).'))
// Reihenfolge: erst die Löschung nach 30 Tagen, dann die Ausnahme, dann der Export; „Bis dahin“ hätte sich auf die Datenschutzerklärung beziehen lassen
assert.ok(abs6.indexOf('30 Tage danach.') < abs6.indexOf(ticket) && abs6.indexOf(ticket) < abs6.indexOf('Bis zur Löschung kann der Kunde die Termine'))
assert.ok(!abs6.includes('Bis dahin'))
assert.ok(tomZeilen()['Löschung'].includes('das Einrichtungs-Ticket bleibt als Arbeitsnachweis mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6)'))
assert.ok(norm(abschnitt(LB, 8).text).includes('Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6 des AVV Terminplaner).'))
// die Zahl „acht“ ist die der Kurzfassung (einbauanleitung.js KURZ_NAMEN); die Kurzfassung hat keinen Zugriff auf Buchungen (ticketDetails.js liest nur den Stand der Einstellungen)
assert.match(lies('server/services/termine/einbauanleitung.js'), /const KURZ_NAMEN = 8\b/)
assert.doesNotMatch(lies('server/services/termine/ticketDetails.js'), /termineBookings|buchungen\.js|gastName|gastEmail/)
// Datenschutzerklärung Abschnitt 6 nennt die Arbeitsnachweise und die Aufbewahrung bis zur Verjährung
const ds = norm(lies('public/datenschutz.html').replace(/<[^>]+>/g, ' '))
assert.ok(ds.includes('unsere Arbeitsnachweise') && ds.includes('bis zum Ablauf der Verjährungsfrist (in der Regel drei Jahre)'))
// T3: „Gastdaten löschen“ löscht nur Einträge im Status wartet (verwerfeWartende); Einträge im Status fehler behalten Adresse und Inhalt bis 48 Stunden nach dem Fehlschlag
const nr8 = avvNr(8)
assert.ok(nr8.includes('Dabei löscht WEBklar die E-Mails zu dieser Buchung, die noch auf den Versand warten, und die Links des Gastes werden ungültig.'))
assert.ok(nr8.includes('E-Mails, deren Versand endgültig fehlgeschlagen ist, behalten Empfängeradresse und Inhalt, bis WEBklar sie nach Nummer 9 Abs. 4 löscht (48 Stunden nach dem Fehlschlag).'))
assert.ok(!nr8.includes('Noch nicht versandte E-Mails zu dieser Buchung löscht WEBklar dabei'), 'der alte Satz versprach mehr, als verwerfeWartende löscht')
const verwerfe = /export async function verwerfeWartende[\s\S]*?\n\}\n/.exec(OUTBOX)?.[0]
assert.ok(verwerfe, 'verwerfeWartende')
assert.ok(verwerfe.includes("Query.equal('status', 'wartet')") && !verwerfe.includes("'fehler'"), 'verwerfeWartende löscht nur den Status wartet')
assert.match(OUTBOX, /const FEHLER_BEHALTEN_MS = 48 \* STUNDE_MS/)
assert.ok(avvNr(9).includes('löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag'))
})
/**
* Fundstellen der Anlage TOM: jede Zeile der Tabelle nennt hier, wo im Code, in den Tests oder in den Abo-Bedingungen ihre
* Aussage gesichert ist. { datei, hat } = die Datei enthält das Muster; { datei, nicht } = die Datei enthält es nicht.
@@ -730,6 +764,9 @@ const TOM_FUNDSTELLEN = {
{ datei: 'server/services/termine/entwuerfe.js', hat: /const funktion = await holeOderNull\(config\.collections\.portalFeatures, id\)/ },
// die Warteschlange löscht die gelisteten Einträge ohne erneutes Lesen (nur Buchungen, abgeschaltete Funktionen und Entwürfe lesen frisch)
{ datei: 'server/services/termine/outbox.js', hat: /await deleteDocument\(col\(\), eintrag\.\$id\)/ },
// das Einrichtungs-Ticket (Kurzfassung der Einstellungen, ohne Daten von Gästen) bleibt als Arbeitsnachweis
{ datei: 'server/services/termine/ticketDetails.js', hat: /const \{ kurz \} = einbauanleitung\(/ },
{ datei: 'server/services/termine/einbauanleitung.js', hat: /const KURZ_NAMEN = 8 / },
],
'Protokolle': [
{ datei: 'test/termineJobs.test.js', hat: /kein Gastname und keine Gastadresse im Log/ },
+2 -2
View File
@@ -311,9 +311,9 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminp
assert.match(html, /<section class="kb-view" id="view-termine">[\s\S]*<h1>Terminplaner<\/h1>[\s\S]*<div id="termine-body">/)
assert.match(html, /<link rel="stylesheet" href="\/termine\.css">/)
// R4: funktionen.js lädt keine Terminplaner-Module (die Kachel steht inline); seine absoluten Importe biegt
// test/funktionenTiles.test.js um – genau diese beiden (/geraete.js kam mit der Heatmap im Gerätrahmen auf main)
// test/funktionenTiles.test.js um – genau diese beiden (die Analyse-Oberfläche liegt in analyse.js, mit relativen Importen)
const fn = lies('public/funktionen.js')
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"])
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/fn-util.js'", "from '/analyse-view.js'"])
assert.doesNotMatch(fn, /from '\/termine/)
// dazu genau ein relativer Import: die gemeinsame Quelle des Links „Zu Verträge“ (Reiner Datenmodul, kein Terminplaner-Modul; C10b)
assert.deepEqual(fn.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"])
+317 -42
View File
@@ -1,4 +1,5 @@
// Playwright-Check Analyse-Panel (Einschalten-Dialog + aktives Panel): statischer Server fuer public/ + gemockte API.
// Playwright-Check Analyse (Cockpit): statischer Server fuer public/ + gemockte API.
// Zustaende: Einschalten, aktiv (volle Daten, neu, wenig Daten, Dev-Modus, alter Collector, langer Pfad, 503), Reiter, Zeitraum.
// Nicht Teil von npm test. Aufruf: node test/ui/analyse.ui.mjs [Ausgabeordner]
import http from 'node:http'
import { readFile, mkdir } from 'node:fs/promises'
@@ -7,7 +8,7 @@ import { fileURLToPath } from 'node:url'
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const SHOTS = process.argv[2] || '/root/reports/website-analyse-2026-10-01'
const SHOTS = process.argv[2] || '/root/reports/analyse-ueberarbeitung-2026-10-04/screens'
await mkdir(SHOTS, { recursive: true })
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' }
const server = http.createServer(async (req, res) => {
@@ -22,83 +23,357 @@ const server = http.createServer(async (req, res) => {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
/* --- Testdaten: Form wie analytics/src/stats.js, Werte wie die Fixture "Boulderhalle Überhang" --- */
const legal = { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' }
const controller = { ok: true, company: 'Bäckerei Berg GmbH', address: 'Hauptstraße 1, 12345 Musterstadt', email: 'info@baeckerei-berg.example' }
const base1 = { projectId: 'p1', projectName: 'Bäckerei Berg', liveUrl: 'https://baeckerei.example', features: { news: { state: 'off' } } }
const baseProject = { projectId: 'p1', projectName: 'Boulderhalle Überhang', liveUrl: 'https://baeckerei.example', features: { news: { state: 'off' } } }
const analyse = (over) => ({ state: 'active', eligible: true, devMode: false, accentColor: '#1d4ed8', avvVersion: '2026-10-02', activatedAt: '2026-08-12T09:00:00.000Z', legal, controller, ...over })
const project = (over = {}) => ({ ...baseProject, analyse: analyse(over) })
const TODAY = '2026-10-04'
const addDays = (day, n) => new Date(Date.UTC(+day.slice(0, 4), +day.slice(5, 7) - 1, +day.slice(8, 10) + n)).toISOString().slice(0, 10)
const daysOf = (from, n, base, spike = -1) => Array.from({ length: n }, (_, i) => ({
day: addDays(from, i), pv: i === spike ? 371 : Math.round(base * (1 + 0.25 * Math.sin(i / 2.3))), visitors: 80 + (i % 7) * 4, contacts: 4 + (i % 5), avgSec: 100 + (i % 9) * 3,
}))
const TOTALS = {
7: { pv: 1640, visitors: 560, contacts: 41, consentRate: 0.61, engaged: 400, avgSec: 101, avgScroll: 62 },
30: { pv: 6930, visitors: 2410, contacts: 188, consentRate: 0.61, engaged: 1800, avgSec: 108, avgScroll: 64 },
90: { pv: 19800, visitors: 6700, contacts: 530, consentRate: 0.6, engaged: 5000, avgSec: 111, avgScroll: 63 },
}
const BEFORE = {
7: { pv: 1500, visitors: 520, contacts: 35, consentRate: 0.6, engaged: 380, avgSec: 99, avgScroll: 60 },
30: { pv: 6080, visitors: 2210, contacts: 143, consentRate: 0.6, engaged: 1500, avgSec: 114, avgScroll: 60 },
90: { pv: 17900, visitors: 6200, contacts: 470, consentRate: 0.6, engaged: 4500, avgSec: 112, avgScroll: 61 },
}
const SCALE = { 7: 0.25, 30: 1, 90: 3 } // Anteil der 30-Tage-Zahlen je Zeitraum
const PAGES = [['/', 2480], ['/preise', 1310], ['/kurse', 1150], ['/routen', 960], ['/oeffnungszeiten', 640], ['/team', 410], ['/kontakt', 300], ['/impressum', 120], ['/datenschutz', 90]]
// Der Zeitraum endet gestern (abgeschlossene Tage); heute steht getrennt in `today`
function makeStats(range, over = {}) {
const from = addDays(TODAY, -range)
const pfrom = addDays(from, -range)
return {
range, from, to: addDays(TODAY, -1), firstDay: addDays(pfrom, -40), totals: TOTALS[range], series: daysOf(from, range, 231, Math.min(14, range - 1)),
today: { day: TODAY, pv: 42, visitors: 17, contacts: 2 },
pages: PAGES.map(([key, count]) => ({ key, count: Math.round(count * SCALE[range]) })),
refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }].map((r) => ({ ...r, count: Math.round(r.count * SCALE[range]) })),
countries: [{ key: 'DE', count: 6000 }, { key: 'AT', count: 600 }, { key: 'CH', count: 330 }],
devices: [{ key: 'm', count: 4590 }, { key: 'd', count: 2030 }, { key: 't', count: 310 }],
contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 41 }, { key: 'wa', count: 38 }, { key: 'out', count: 13 }],
banner: { shown: 3950, accept: 2410, reject: 1540 },
previous: {
from: pfrom, to: addDays(from, -1), complete: true, totals: BEFORE[range], series: daysOf(pfrom, range, 200),
contacts: [{ key: 'tel', count: 75 }, { key: 'mail', count: 32 }, { key: 'wa', count: 21 }, { key: 'out', count: 13 }],
},
...over,
}
}
const longPath = `/${'a'.repeat(120)}`
const OLD = (range) => ({
totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: daysOf(addDays(TODAY, -13), 14, 40).map(({ day, pv }) => ({ day, pv })),
pages: [{ key: '/', count: 300 }], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 10, accept: 6, reject: 4 }, range,
})
// je Zustand: die Website und die Statistik (nach range)
const SCENARIOS = {
off: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } },
missing: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } } },
active: { ...base1, analyse: { state: 'active', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } },
}
const days = Array.from({ length: 14 }, (_, i) => ({ day: `2026-09-${String(17 + i).padStart(2, '0')}`, pv: 20 + ((i * 7) % 13) * 4 }))
const stats = {
totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: days,
pages: [{ key: '/', count: 300 }, { key: '/kontakt', count: 120 }], refs: [{ key: '', count: 200 }, { key: 'google.com', count: 90 }],
countries: [{ key: 'DE', count: 500 }], devices: [{ key: 'm', count: 380 }, { key: 'd', count: 230 }], contacts: [{ key: 'tel', count: 12 }],
banner: { shown: 300, accept: 186, reject: 114 },
off: { project: project({ state: 'off' }) },
missing: { project: project({ state: 'off', controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } }) },
full: { project: project(), stats: (r) => makeStats(r) },
neu: { project: project(), stats: (r) => makeStats(r, { firstDay: addDays(TODAY, -12), previous: { ...makeStats(r).previous, complete: false } }) },
wenig: { project: project(), stats: (r) => makeStats(r, { totals: { ...TOTALS[r], pv: 12 } }) },
dev: { project: project({ devMode: true }), stats: (r) => makeStats(r) },
alt: { project: project(), stats: (r) => OLD(r) },
stark: { project: project(), stats: (r) => makeStats(r, { series: daysOf(addDays(TODAY, -r), r, 2400, Math.min(14, r - 1)) }) },
sehrStark: { project: project(), stats: (r) => makeStats(r, { series: daysOf(addDays(TODAY, -r), r, 12000, Math.min(14, r - 1)) }) },
langerPfad: { project: project(), stats: (r) => makeStats(r, { pages: [{ key: longPath, count: 900 }, { key: '/kurz', count: 500 }], refs: [{ key: `${'h'.repeat(90)}.example.com`, count: 300 }] }) },
fehler: { project: project(), stats: null },
zwei: { projects: [project(), { ...baseProject, projectId: 'p2', projectName: 'Zweite Website mit einem wirklich sehr langen Namen, der nicht in eine Zeile passt', analyse: analyse({}) }], stats: (r) => makeStats(r) },
}
let scenario = 'off'
const browser = await chromium.launch()
let statsRequests = []
let delayFirst30 = 0
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const launch = () => chromium.launch().catch(() => chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome' }))
const browser = await launch()
let failed = 0
const check = (name, ok) => { console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`); if (!ok) failed += 1 }
const TIMEOUT = Number(process.env.UI_TIMEOUT) || 120000
try {
for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) {
const ctx = await browser.newContext({ viewport })
for (const [label, viewport, touch] of [['desktop', { width: 1440, height: 1000 }, false], ['phone', { width: 402, height: 874 }, true]]) {
const ctx = await browser.newContext({ viewport, hasTouch: touch })
ctx.setDefaultTimeout(TIMEOUT)
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await page.route('**/api/**', async (route) => {
const p = new URL(route.request().url()).pathname
const json = (b) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(b) })
const url = new URL(route.request().url())
const p = url.pathname
const json = (b, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(b) }).catch(() => {})
const sc = SCENARIOS[scenario]
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Boulderhalle Überhang', status: 'live' }] })
if (p === '/api/features') return json({ features: [] })
if (p === '/api/activity') return json({ tickets: [] })
if (p === '/api/funktionen') return json({ projects: [SCENARIOS[scenario]] })
if (p === '/api/funktionen/analyse/stats') return json(stats)
if (p === '/api/funktionen') return json({ projects: sc.projects || [sc.project] })
if (p === '/api/funktionen/analyse/stats') {
const range = Number(url.searchParams.get('range'))
statsRequests.push(range)
if (range === 30 && delayFirst30 > 0) {
delayFirst30 -= 1
await sleep(1500)
}
if (!sc.stats) return json({ error: 'Auswertung nicht erreichbar' }, 503)
return json(sc.stats(range))
}
if (p === '/api/funktionen/analyse/heatmap-link') return json({ url: `${base}/offline.html` })
if (p === '/api/funktionen/analyse/deactivate') return json({ ok: true })
return json({})
})
const open = async (sc) => {
// waitFor: Selektor, der den fertigen Zustand anzeigt
const open = async (sc, waitFor = '.an-ov:not(.an-skeleton)') => {
scenario = sc
statsRequests = []
await page.goto('about:blank')
await page.goto(`${base}/dashboard.html#analyse`)
await page.waitForSelector('#analyse-body .kb-panel')
await page.waitForTimeout(900)
await page.waitForSelector('#analyse-body .an')
await page.waitForSelector(waitFor)
await page.waitForTimeout(300)
}
const shot = async (name) => {
const height = await page.evaluate(() => Math.min(document.documentElement.scrollHeight, 3200))
const height = await page.evaluate(() => Math.min(document.documentElement.scrollHeight, 4200))
await page.setViewportSize({ width: viewport.width, height: Math.max(viewport.height, height) })
await page.waitForTimeout(150)
await page.screenshot({ path: `${SHOTS}/task-7-${label}-${name}.png` })
await page.waitForTimeout(250)
await page.screenshot({ path: `${SHOTS}/${label}-${name}.png` })
await page.setViewportSize(viewport)
}
const noScroll = async (n) => check(`${label} kein_horizontaler_scroll ${n}`, await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth))
const noScroll = () => page.evaluate(() => document.scrollingElement.scrollWidth <= window.innerWidth)
const text = (sel) => page.evaluate((s) => document.querySelector(s)?.textContent ?? '', sel)
const count = (sel) => page.evaluate((s) => document.querySelectorAll(s).length, sel)
const L = (name) => `${label} ${name}`
await open('off')
const text = await page.textContent('#analyse-body')
check(`${label} verantwortlicher`, text.includes('Bäckerei Berg GmbH') && text.includes('info@baeckerei-berg.example'))
check(`${label} links`, (await page.$$eval('#analyse-body a[href^="/rechtliches/"]', (a) => a.map((x) => x.getAttribute('href')))).join() === `${legal.avv},${legal.lb}`)
check(`${label} knopf_gesperrt_ohne_haken`, await page.isDisabled('#an-on'))
/* --- Einschalten (wie bisher) --- */
await open('off', '#an-on')
const body = await text('#analyse-body')
const linkOk = (await page.$$eval('#analyse-body a[href^="/rechtliches/"]', (a) => a.map((x) => x.getAttribute('href')))).join() === `${legal.avv},${legal.lb}`
const disabledFirst = await page.isDisabled('#an-on')
const preview = (await text('#an-preview')).includes('Annehmen') && (await text('#an-preview')).includes('Ablehnen')
await page.check('#an-avv')
check(`${label} knopf_frei_mit_haken`, !(await page.isDisabled('#an-on')))
await noScroll('einschalten')
const enabled = !(await page.isDisabled('#an-on'))
check(L('Einschalten: Banner-Vorschau, Häkchen schaltet Knopf frei (wie bisher)'), body.includes('Bäckerei Berg GmbH') && body.includes('info@baeckerei-berg.example') && linkOk && preview && disabledFirst && enabled)
check(L('Einschalten: Kopfleiste ohne Zeitraum und Reiter, Status "Analyse ist noch ausgeschaltet"'), (await count('.an-top')) === 1 && (await count('.an-seg')) === 0 && (await count('.an-tabs')) === 0 && (await text('.an-status')).includes('Analyse ist noch ausgeschaltet'))
check(L('kein_horizontaler_scroll einschalten'), await noScroll())
await shot('einschalten')
await open('missing')
await open('missing', '#an-missing')
await page.check('#an-avv')
check(`${label} gesperrt_ohne_angaben`, (await page.isDisabled('#an-on')) && Boolean(await page.$('#an-missing')))
check(L('gesperrt_ohne_angaben'), (await page.isDisabled('#an-on')) && Boolean(await page.$('#an-missing')))
await shot('fehlende-angaben')
await open('active')
await page.waitForSelector('.an-kpis')
await noScroll('aktiv')
/* --- aktiv, volle Daten --- */
await open('full')
check(L('aktiv: Block "Das Wichtigste" mit "14 % mehr"'), (await text('.an-sum')).includes('Das Wichtigste') && (await text('.an-sum')).includes('14 % mehr'))
check(L('aktiv: 4 Kacheln, 3 Chips ▲, 1 Chip ▼'), (await count('.an-kpis .an-kpi')) === 4 && (await count('.an-kpis .an-delta.up')) === 3 && (await count('.an-kpis .an-delta.down')) === 1 && (await text('.an-kpis')).includes('▲') && (await text('.an-kpis')).includes('▼'))
check(L('aktiv: Kopfleiste mit "Heute bisher: 42 Aufrufe" und Stand-Uhrzeit, Zeitraum 30 Tage gewählt, Reiter Übersicht gewählt'), /^Analyse aktiv · Heute bisher: 42 Aufrufe · Stand: \d\d:\d\d Uhr$/.test(await text('.an-status')) && (await page.getAttribute('.an-seg .on', 'data-range')) === '30' && (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'uebersicht')
check(L('kein_horizontaler_scroll aktiv'), await noScroll())
// Raster: Desktop 4 Kacheln nebeneinander, Handy 2 x 2; Diagramm und Kontaktkarte gleich hoch (Desktop)
const tiles = await page.$$eval('.an-kpis .an-kpi', (els) => els.map((e) => { const r = e.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width) } }))
check(L(touch ? 'Kacheln 2 × 2' : 'Kacheln 4 nebeneinander'), touch
? tiles[0].y === tiles[1].y && tiles[2].y === tiles[3].y && tiles[2].y > tiles[0].y && tiles[0].x === tiles[2].x && tiles[1].x > tiles[0].x
: tiles.every((t) => t.y === tiles[0].y))
await page.evaluate(() => document.querySelector('.an-chart').scrollIntoView({ block: 'center' }))
await page.waitForTimeout(200)
const boxes = await page.evaluate(() => {
const r = (s) => { const b = document.querySelector(s).getBoundingClientRect(); return { top: Math.round(b.top), height: Math.round(b.height), left: b.left, right: b.right } }
return { chart: r('.an-chartbox'), contacts: r('.an-contacts'), svg: r('.an-chart') }
})
if (!touch) {
check(L(`Diagramm und Kontaktkarte gleich hoch (${boxes.chart.height} / ${boxes.contacts.height} px)`), boxes.chart.top === boxes.contacts.top && Math.abs(boxes.chart.height - boxes.contacts.height) <= 1)
check(L(`Diagramm füllt seine Karte (${boxes.svg.height} von ${boxes.chart.height} px)`), boxes.svg.height >= boxes.chart.height * 0.6)
} else {
check(L('Handy: Diagramm und Kontaktkarte untereinander'), boxes.contacts.top > boxes.chart.top && boxes.chart.left === boxes.contacts.left)
}
// Blase am Diagramm: nächster Tag, zeigt "…: N Aufrufe (davor M)"; Maus verlässt / Tippen daneben blendet aus
const svg = boxes.svg
const px = svg.left + (svg.right - svg.left) * 0.62
const py = svg.top + svg.height / 2
if (touch) {
await page.touchscreen.tap(px, py)
} else {
await page.mouse.move(px, py)
}
await page.waitForSelector('.an-tip:not([hidden])')
const tip = await text('.an-tip')
check(L(`Diagramm-Blase: "${tip}"`), /^\S+, \d\d\.\d\d\.: [\d.]+ Aufrufe \(davor [\d.]+\)$/.test(tip) && (await count('.an-dot.on')) === 1)
if (touch) {
// neben das Diagramm tippen (nicht in die App-Leiste oben, die führt zurück)
const h = await page.locator('.an-chartbox h3').boundingBox()
await page.touchscreen.tap(h.x + 10, h.y + h.height / 2)
} else {
await page.mouse.move(2, 2)
}
await page.waitForFunction(() => document.querySelector('.an-tip')?.hidden === true)
check(L('Diagramm-Blase: verschwindet wieder, kein Punkt bleibt markiert'), (await count('.an-dot.on')) === 0)
// Achsenbeschriftung: gerendert ca. 11-12 px (viewBox 640 skaliert mit der Breite), nichts ragt aus der Karte, x-Beschriftungen überlappen nicht
const axis = () => page.evaluate(() => {
const svg = document.querySelector('.an-chart')
const scale = svg.getBoundingClientRect().width / 640
const box = document.querySelector('.an-chartbox').getBoundingClientRect()
const texts = [...svg.querySelectorAll('.an-axis')].map((t) => ({ r: t.getBoundingClientRect(), px: parseFloat(getComputedStyle(t).fontSize) * scale, anchor: t.getAttribute('text-anchor'), s: t.textContent }))
const xs = texts.filter((t) => t.anchor === 'middle').sort((a, b) => a.r.left - b.r.left)
return {
px: Math.min(...texts.map((t) => t.px)), pxMax: Math.max(...texts.map((t) => t.px)),
inside: texts.every((t) => t.r.left >= box.left + 1 && t.r.right <= box.right - 1),
gap: xs.every((t, i) => !i || t.r.left >= xs[i - 1].r.right + 4),
y: texts.filter((t) => t.anchor === 'end').map((t) => t.s).join(','),
}
})
const ax = await axis()
check(L(`Achsenbeschriftung ca. 11-12 px (${ax.px.toFixed(1)} px), liegt in der Karte, x-Werte überlappen nicht`), ax.px >= 10.5 && ax.pxMax <= 13 && ax.inside && ax.gap)
const faint = await page.evaluate(() => getComputedStyle(document.querySelector('.an-credit')).color)
check(L(`Hilfstext "--an-faint" kontrastreich (${faint})`), Number(/rgba?\([^)]*?,\s*([\d.]+)\)$/.exec(faint)?.[1] ?? 1) >= 0.55)
if (touch) {
const heights = await page.evaluate(() => Object.fromEntries(['.an-tabbtn', '.an-seg button', '.an-ltab', '.an-more', '.an-teaser .an-btn'].map((sel) => [sel, Math.min(...[...document.querySelectorAll(sel)].map((e) => e.getBoundingClientRect().height))])))
check(L(`Tippflächen mindestens 40 px: ${Object.entries(heights).map(([k, v]) => `${k} ${v.toFixed(0)}`).join(', ')}`), Object.values(heights).every((h) => h >= 39.5))
}
// Ranglisten: Umschalter und "Alle anzeigen" ändern nur ihre Karte
check(L('Rangliste: 6 Zeilen und "Alle anzeigen"'), (await count('[data-rank="seen"] .an-lr')) === 6 && (await count('[data-rank="seen"] [data-rank-more]')) === 1)
await page.click('[data-rank="seen"] [data-rank-more]')
check(L('Rangliste: "Alle anzeigen" zeigt alle 9 Seiten'), (await count('[data-rank="seen"] .an-lr')) === 9 && (await count('[data-rank="seen"] [data-rank-more]')) === 0)
await page.click('[data-rank="seen"] [data-rank-tab="refs"]')
check(L('Rangliste: Umschalter Herkunft, Seiten-Reiter der anderen Karte unverändert'), (await page.getAttribute('[data-rank="seen"]', 'data-rank-active')) === 'refs' && (await page.getAttribute('[data-rank="who"]', 'data-rank-active')) === 'devices' && (await text('[data-rank="seen"]')).includes('Google'))
await shot('aktiv')
// Reiter per Pfeiltaste (Reihenfolge Übersicht, Heatmap, Einstellungen)
await page.focus('.an-tabbtn[data-tab="uebersicht"]')
await page.keyboard.press('ArrowRight')
await page.waitForSelector('.an-tabbtn[data-tab="heatmap"][aria-selected="true"]')
await page.keyboard.press('ArrowLeft')
await page.waitForSelector('.an-tabbtn[data-tab="uebersicht"][aria-selected="true"]')
check(L('Reiter: Pfeiltasten wechseln die Reiter'), (await count('.an-ov:not(.an-skeleton) .an-kpis')) === 1)
// Zeitraum 7: neue Anfrage, nur die Übersicht lädt neu, Reiter bleibt
const before = statsRequests.length
await page.click('.an-seg [data-range="7"]')
await page.waitForFunction(() => document.querySelector('.an-lead')?.textContent.includes('7 Tagen'))
check(L('Zeitraum 7 → Anfrage range=7, Reiter bleibt Übersicht'), statsRequests.length > before && statsRequests.at(-1) === 7 && (await page.getAttribute('.an-seg .on', 'data-range')) === '7' && (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'uebersicht')
// Heatmap: iframes erst nach dem Reiterwechsel (Teaser-Knopf und Reiterknopf wechseln beide)
await open('full')
const iframesBefore = await count('iframe')
await page.click('.an-teaser [data-tab="heatmap"]')
await page.waitForFunction(() => document.querySelectorAll('.hs-dev iframe').length === 2)
check(L('Teaser "Öffnen" wechselt in den Reiter Heatmap'), (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'heatmap' && (await count('.an-top .an-seg')) === 0)
await open('full')
await page.click('.an-tabbtn[data-tab="heatmap"]')
await page.waitForFunction(() => document.querySelectorAll('.hs-dev iframe').length === 2)
check(L('Reiter Heatmap: vorher 0 iframes, nach Klick 2 iframes'), iframesBefore === 0 && (await count('.hs-dev iframe')) === 2 && (await count('#an-heat-page')) === 1)
check(L('Heatmap: Seitenauswahl und Computer/Tablet-Schalter in einer Leiste'), await page.evaluate(() => { const bar = document.querySelector('.an-heat-bar'); return Boolean(bar?.querySelector('#an-heat-page') && bar.querySelector('.hs-switch button')) }))
check(L('kein_horizontaler_scroll heatmap'), await noScroll())
await page.waitForTimeout(600)
await shot('heatmap')
// zurück in die Übersicht: die iframes verschwinden, die Zahlen stehen sofort wieder da
await page.click('.an-tabbtn[data-tab="uebersicht"]')
await page.waitForSelector('.an-ov:not(.an-skeleton) .an-kpis')
check(L('Reiterwechsel zurück: Übersicht ohne iframes'), (await count('iframe')) === 0)
// Einstellungen
await page.click('.an-tabbtn[data-tab="einstellungen"]')
await page.waitForSelector('#an-off-ask')
const settings = await text('#analyse-body')
check(L('Reiter Einstellungen: "Analyse ausschalten" sichtbar, "aktiv seit"'), (await page.isVisible('#an-off-ask')) && settings.includes('Analyse ausschalten') && /aktiv seit 12\.08\.2026/.test(settings) && settings.includes('Fassung 2026-10-02') && (await count('.an-docs a')) === 2 && (await count('.an-swatch')) === 1 && (await count('.an-top .an-seg')) === 0)
check(L('kein_horizontaler_scroll einstellungen'), await noScroll())
const errColor = await page.evaluate(() => {
const p = document.createElement('p')
p.className = 'error'
p.textContent = 'x'
document.querySelector('#an-off').appendChild(p)
const c = getComputedStyle(p).color
p.remove()
return c
})
check(L(`Fehlertext beim Ausschalten rot (${errColor})`), errColor === 'rgb(252, 165, 165)')
await shot('einstellungen')
await page.click('#an-off-ask')
await page.click('#an-off-yes')
await page.waitForSelector('#an-off-done')
check(`${label} ausschalten_bestaetigung`, (await page.textContent('#an-off-done')).includes('Ab jetzt werden keine Daten mehr gespeichert'))
check(L('ausschalten_bestaetigung'), (await text('#an-off-done')).includes('Ab jetzt werden keine Daten mehr gespeichert'))
await shot('ausgeschaltet')
check(`${label} keine_js_fehler`, errors.length === 0)
// verzögerte erste Antwort überschreibt die spätere nicht
delayFirst30 = 1
await open('full', '.an-seg')
await page.click('.an-seg [data-range="7"]')
await page.waitForFunction(() => document.querySelector('.an-lead')?.textContent.includes('7 Tagen'))
await page.waitForTimeout(1900)
const lateOk = (await text('.an-lead')).includes('7 Tagen') && !(await text('.an-lead')).includes('30 Tagen') && statsRequests.join() === '30,7' && (await page.getAttribute('.an-seg .on', 'data-range')) === '7'
// auch der Zustand dahinter bleibt der 7-Tage-Stand: "Alle anzeigen" und der Umschalter malen mit 7-Tage-Zahlen (Startseite 620, Google 220)
const firstRow = (card) => text(`[data-rank="${card}"] .an-lr`)
const before7 = await firstRow('seen')
await page.click('[data-rank="seen"] [data-rank-more]')
const more = await firstRow('seen')
await page.click('[data-rank="seen"] [data-rank-tab="refs"]')
const refs = await firstRow('seen')
check(L('verzögerte erste Antwort (range=30, 1,5 s) überschreibt die spätere (range=7) nicht'), lateOk && before7.includes('620') && more.includes('620') && !more.includes('2.480') && refs.includes('220') && !refs.includes('880') && (await count('[data-rank="seen"] .an-lr')) === 4)
delayFirst30 = 0
// Heatmap-Reiter während der ersten Übersicht-Anfrage: nutzt sie mit, statt dieselbe Anfrage noch einmal zu stellen
delayFirst30 = 1
await open('full', '.an-seg')
await page.click('.an-tabbtn[data-tab="heatmap"]')
await page.waitForFunction(() => document.querySelectorAll('.hs-dev iframe').length === 2)
check(L('Heatmap-Reiter nutzt die laufende 30-Tage-Anfrage mit'), statsRequests.join() === '30')
delayFirst30 = 0
// Website wechseln: Reiter bleibt
await open('zwei')
await page.click('.an-tabbtn[data-tab="einstellungen"]')
await page.waitForSelector('#an-off-ask')
check(L('mehrere Websites: Auswahlfeld in der Kopfleiste, langer Name bricht nichts'), (await count('#an-project option')) === 2 && (await noScroll()))
await page.selectOption('#an-project', 'p2')
await page.waitForSelector('#an-off-ask')
check(L('Websitewechsel: Reiter Einstellungen bleibt'), (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'einstellungen' && (await page.inputValue('#an-project')) === 'p2')
await shot('zwei-websites')
/* --- weitere Zustände --- */
await open('neu')
check(L('neu: "Vergleich ab" statt Chips, Satz "Seit dem Start der Analyse"'), (await count('.an-kpis .an-delta')) === 0 && (await text('.an-kpis')).includes('Vergleich ab') && (await text('.an-sum')).includes('Seit dem Start der Analyse') && (await count('.an-prev')) === 0)
await shot('neu')
await open('wenig')
check(L('wenig: Leerzustand'), (await count('.an-sum-empty')) === 1 && (await text('.an-sum')).includes('Sobald ein paar mehr Besuche da sind'))
await shot('wenig')
await open('dev')
check(L('dev: "Im Dev-Modus wird nicht gemessen."'), (await text('.an-status')).includes('Im Dev-Modus wird nicht gemessen.') && (await page.getAttribute('.an-site', 'data-state')) === 'warn')
await shot('dev')
await open('alt')
check(L('alt: kein "Heute bisher" (alter Collector), Stand bleibt'), /^Analyse aktiv · Stand: \d\d:\d\d Uhr$/.test(await text('.an-status')))
check(L('alt: keine Chips, keine pageerror'), (await count('.an-delta')) === 0 && (await count('.an-prev')) === 0 && errors.length === 0 && (await text('.an-kpis')).includes('612'))
await shot('alt')
await open('fehler', '.an-error')
const requestsBefore = statsRequests.length
check(L('fehler: "Auswertung gerade nicht erreichbar" + Neu laden'), (await text('.an-error')).includes('Auswertung gerade nicht erreichbar') && (await text('#an-retry')) === 'Neu laden' && (await count('.an-tabs')) === 1)
await page.click('#an-retry')
for (let i = 0; i < 50 && statsRequests.length === requestsBefore; i += 1) await sleep(100)
await page.waitForSelector('.an-error')
check(L('fehler: "Neu laden" fragt erneut an'), statsRequests.length === requestsBefore + 1)
await shot('fehler')
for (const [sc, label5] of [['stark', 'vierstellige'], ['sehrStark', 'fünfstellige']]) {
await open(sc)
const big = await axis()
check(L(`${label5} y-Werte (${big.y}) nicht abgeschnitten, x-Werte überlappen nicht, Schrift ${big.px.toFixed(1)} px`), big.inside && big.gap && big.px >= 10.5 && big.pxMax <= 13)
await shot(sc === 'stark' ? 'skala-4-stellig' : 'skala-5-stellig')
}
await open('langerPfad')
const name = await page.evaluate(() => { const n = document.querySelector('[data-rank="seen"] .an-name'); return { sw: n.scrollWidth, cw: n.clientWidth, title: n.title } })
check(L(`langer Pfad${touch ? ', 402 px' : ''}: document.scrollingElement.scrollWidth <= innerWidth`), (await noScroll()) && name.sw > name.cw && name.title.length > 100)
await shot('langer-pfad')
check(L('keine pageerror in allen Zuständen'), errors.length === 0)
if (errors.length) console.log(errors.join('\n'))
await ctx.close()
}
} finally {
+795
View File
@@ -0,0 +1,795 @@
// End-to-end-Pruefung des Webshops im Browser (Playwright, Chromium), gegen die echten Router hinter Nachbauten von Appwrite, Stripe (Connect)
// und Mail (test/ui/shopTestServer.mjs). Nicht Teil von npm test.
// Aufruf: nice node --max-old-space-size=1024 test/ui/shop.ui.mjs (vorher free -m; es laeuft nur ein Browser)
// Screenshots: /root/reports/webshop-2026-10-05/ui/ (Desktop 1280x900 und Telefon 390x844). Exit-Code 1, wenn ein Schritt scheitert.
//
// Ein durchgehendes Szenario in acht Schritten (jeder Schritt baut auf dem vorigen auf):
// 1 Kunde (Pilot): Kachel Webshop, Anfrage stellen 5 Mails G09 und K25
// 2 Team nimmt an, Bestellseite, Zahlung, Freischaltung 6 Haendler: Bestellung, versendet (G10), Erstattung (G13)
// 3 Reiter Einstellungen, Rechtliches, Artikel, Uebersicht 7 Widerruf (G12, K26)
// 4 Kaeufer: Seite /shop/<siteKey>, Demo-Seite, Kasse, Zahlung 8 Shop schliessen, Widerruf bleibt erreichbar
// Stripe-Adressen (https://*.stripe.test) werden per page.route abgefangen; Mitarbeiter-Schritte rufen die Dienste direkt auf.
// NUR=<Teil des Namens> fuehrt nur passende Schritte aus (setzt voraus, dass die vorigen Zustaende schon bestehen: meist sinnlos).
import { mkdir, readdir, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import path from 'node:path'
import { startShopUiServer, ANFRAGE, KUNDEN_EMAIL } from './shopTestServer.mjs'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core'
const { chromium } = createRequire(import.meta.url)(PW)
const sharp = createRequire(import.meta.url)('sharp')
const CHROME = process.env.CHROME_BIN || path.join(process.env.HOME || '/root', '.cache/ms-playwright/chromium-1234/chrome-linux64/chrome')
const SHOTS = process.env.SHOTS || '/root/reports/webshop-2026-10-05/ui'
await mkdir(SHOTS, { recursive: true })
for (const datei of await readdir(SHOTS)) if (/\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
const srv = await startShopUiServer()
const DESKTOP = { width: 1280, height: 900 }
const PHONE = { width: 390, height: 844 }
// ---------------------------------------------------------------- Helfer
const ergebnisse = []
let schritt = null
/** Weiche Pruefung: merkt den Fehler und laeuft weiter (Schritt scheitert am Ende). */
function ok(bedingung, text) {
if (!bedingung) {
schritt.fehler.push(text)
console.log(` x ${text}`)
} else if (process.env.VERBOSE) console.log(` + ${text}`)
return Boolean(bedingung)
}
const norm = (t) => String(t ?? '').replace(/ | /g, ' ').replace(/\s+/g, ' ').trim()
const enthaelt = (text, teil) => norm(text).includes(norm(teil))
/** Screenshot der ganzen Seite in Desktop- und Telefonbreite (Fenster auf Seitenhoehe, hoechstens 3600 px). */
async function shot(page, name, { telefon = true } = {}) {
for (const [suffix, groesse] of [['desktop', DESKTOP], ['phone', PHONE]]) {
if (suffix === 'phone' && !telefon) continue
await page.setViewportSize(groesse)
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
await page.waitForTimeout(150)
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3600))
await page.setViewportSize({ width: groesse.width, height: hoehe })
await page.screenshot({ path: `${SHOTS}/${name}-${suffix}.png` })
}
await page.setViewportSize(DESKTOP)
}
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
async function warteBis(bedingung, text, ms = 8000) {
const ende = Date.now() + ms
for (;;) {
if (await bedingung()) return
if (Date.now() > ende) throw new Error(`Zeit abgelaufen: ${text}`)
await new Promise((fertig) => { setTimeout(fertig, 50) })
}
}
/** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/termine-portal.ui.mjs). */
function portalHilfe(p, json) {
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Hanna Brandt', email: KUNDEN_EMAIL } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Töpferei Brandt', status: 'live' }] })
if (p === '/api/features') return json({ features: [] })
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
if (p === '/api/chat/unread-count') return json({ unread: 0 })
return json({})
}
const ECHT = [/^\/api\/funktionen/, /^\/api\/shop/, /^\/api\/public\/shop/, /^\/api\/abo\/webhook/]
// Aufzeichnung der abgefangenen Stripe-Navigationen: { art: 'connect' | 'checkout', url }
const stripeNavigationen = []
const offeneSeiten = new Set() // fuer Fehler-Screenshots
const fremdeHosts = new Set()
/** Seite der Haendler-Website (https://www.toepferei-brandt.example/...): Platzhalter fuer den Einbau des Shops mit data-api (Cross-Origin). */
const kundenSeite = (key) => `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Töpferei Brandt</title>
<style>body{margin:0;font-family:system-ui,sans-serif;background:#faf7f2;color:#2b2b2b}header,footer{padding:16px 24px;background:#e9e2d6}main{max-width:900px;margin:0 auto;padding:24px 16px}.fallback{padding:16px;border:2px dashed #999}</style></head>
<body><header>Töpferei Brandt – Website des Händlers (Testseite)</header><main><h1>Unser Online-Shop</h1>
<div data-webklar-shop="${key}" data-api="${srv.portalUrl}"><p class="fallback">Unser Shop ist gerade nicht erreichbar. Bestellungen telefonisch unter 06302 1234567.</p></div></main>
<footer>Fußbereich</footer><script src="${srv.portalUrl}/embed/shop.js" defer></script></body></html>`
async function neuerKontext(browser, { cookie = true, viewport = DESKTOP } = {}) {
// Service Worker des Portals blockieren: ihre Anfragen umgingen page.route
const ctx = await browser.newContext({ viewport, serviceWorkers: 'block' })
if (cookie) await ctx.addCookies([await srv.portalCookie()])
ctx.setDefaultTimeout(10000)
// Portal (https://project.webklar.test): echte Router des Testservers; die uebrigen Portal-Schnittstellen beantwortet portalHilfe
await ctx.route(`${srv.portalUrl}/**`, async (route) => {
const req = route.request()
const url = new URL(req.url())
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
if (url.pathname.startsWith('/api/') && !ECHT.some((re) => re.test(url.pathname))) return portalHilfe(url.pathname, json)
const headers = await req.allHeaders()
delete headers.host
const antwort = await route.fetch({ url: `${srv.url}${url.pathname}${url.search}`, headers })
return route.fulfill({ response: antwort })
})
// Website des Haendlers: ihre Seiten liefert das Skript (jede Adresse dieselbe Seite, mit oder ohne Rueckkehr-Parameter)
await ctx.route(`${srv.kundenUrl}/**`, async (route) => {
if (new URL(route.request().url()).pathname === '/favicon.ico') return route.fulfill({ status: 404, body: '' })
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: kundenSeite(srv.siteKey()) })
})
// Stripe: Navigation abfangen (Haendler-Onboarding -> zurueck ins Portal; Kasse -> Platzhalterseite, der Test navigiert selbst zurueck)
await ctx.route(/^https:\/\/[a-z]+\.stripe\.test\//, async (route) => {
const url = route.request().url()
const host = new URL(url).host
if (host === 'connect.stripe.test') {
stripeNavigationen.push({ art: 'connect', url })
const zurueck = `${srv.portalUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${srv.PROJEKT}`
return route.fulfill({ status: 200, contentType: 'text/html', body: `<!doctype html><title>Stripe (Nachbau)</title><p>Onboarding</p><script>location.replace(${JSON.stringify(zurueck)})</script>` })
}
stripeNavigationen.push({ art: host === 'checkout.stripe.test' ? 'checkout' : 'sonst', url })
return route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><title>Stripe-Kasse (Nachbau)</title><h1>Stripe-Kasse (Nachbau)</h1>' })
})
ctx.on('page', (seite) => {
offeneSeiten.add(seite)
seite.on('close', () => offeneSeiten.delete(seite))
seite.on('pageerror', (e) => schritt?.fehler.push(`JavaScript-Fehler: ${e.message}`))
seite.on('dialog', (d) => { schritt?.fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
seite.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http') && !u.host.endsWith('.stripe.test')) fremdeHosts.add(u.host) } catch { /* data: u. a. */ } })
seite.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) schritt?.hinweise.push(`Konsole: ${m.text().slice(0, 160)}`) })
})
return ctx
}
let browser = null
async function lauf(name, fn) {
if (process.env.NUR && !name.includes(process.env.NUR)) return
schritt = { name, fehler: [], hinweise: [] }
console.log(`--- ${name}`)
try {
await fn()
} catch (e) {
schritt.fehler.push(`Abbruch: ${String(e.message).split('\n')[0]}`)
if (process.env.STACK) console.log(e.stack)
}
if (schritt.fehler.length) {
let i = 0
for (const seite of offeneSeiten) {
i += 1
await seite.evaluate(() => window.scrollTo(0, 0)).catch(() => {})
const hoehe = await seite.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3600)).catch(() => 900)
await seite.setViewportSize({ width: 1280, height: hoehe }).catch(() => {})
await seite.screenshot({ path: `${SHOTS}/fehler-${name}-${i}.png` }).catch(() => {})
await seite.setViewportSize(DESKTOP).catch(() => {})
}
}
const bestanden = schritt.fehler.length === 0
ergebnisse.push([name, bestanden, schritt.fehler, schritt.hinweise])
console.log(`${bestanden ? 'ok ' : 'FAIL'} ${name}${bestanden ? '' : `: ${schritt.fehler.join(' | ')}`}`)
for (const h of schritt.hinweise) console.log(` ! ${h}`)
}
const oeffne = (page, hash) => page.goto(`${srv.portalUrl}/dashboard.html${hash}`)
const webshopBereit = async (page) => {
await page.locator('#view-webshop.active').waitFor()
await page.locator('#webshop-body :is(.sh-reiterleiste, .sh-anfrage, .sh-anfrage-karte, .tm-bestellen, .sh-info)').first().waitFor()
}
const reiter = async (page, key) => {
await page.locator(`[data-reiter="${key}"]`).click()
await page.locator(`#sh-tab-${key}[aria-selected="true"]`).waitFor()
}
const meldung = (page) => page.locator('.sh-meldung')
const warteMeldung = async (page, teil) => {
await warteBis(async () => enthaelt(await meldung(page).innerText().catch(() => ''), teil), `Meldung „${teil}“`)
}
/** Artikel speichern und warten, bis der Server n Artikel hat; sonst die Feldfehler des Editors melden. */
async function speichereArtikel(page, form, n) {
await form.locator('[data-aktion="speichern"]').click()
const ende = Date.now() + 10000
while (srv.produkte().length < n && Date.now() < ende) {
const fehler = await page.locator('[data-fehler]:not(:empty)').allInnerTexts()
if (fehler.length) throw new Error(`Artikel ${n} nicht gespeichert, Feldfehler: ${fehler.join(' / ')}`)
await new Promise((fertig) => { setTimeout(fertig, 100) })
}
ok(srv.produkte().length === n, `${n}. Artikel gespeichert (${srv.produkte().length})`)
}
const echteMeldungen = async (page) => norm(await meldung(page).innerText().catch(() => ''))
// ---------------------------------------------------------------- Szenario
let portalPage = null // Haendler (mit Cookie)
let ctxHaendler = null
const merk = {} // Zahlen und IDs zwischen den Schritten
async function szenario() {
ctxHaendler = await neuerKontext(browser)
portalPage = await ctxHaendler.newPage()
const page = portalPage
// ------------------------------------------------------------ 1
await lauf('1_kunde_kachel_und_anfrage', async () => {
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="shop"]')
await kachel.waitFor()
ok(enthaelt(await kachel.innerText(), 'Webshop'), 'Kachel Webshop sichtbar')
ok(enthaelt(await kachel.innerText(), '299 €'), 'Kachel nennt den Preis 299 €')
ok(await page.locator('[data-fn-tile="shop"] .fn-tile-cta').innerText().then((t) => enthaelt(t, 'Webshop anfragen')), 'Knopf „Webshop anfragen“')
await shot(page, '01-kachel-webshop')
await kachel.locator('.fn-tile-cta').click()
await webshopBereit(page)
const form = page.getByRole('form', { name: 'Webshop anfragen' })
await form.waitFor()
await shot(page, '02-anfrage-formular')
await form.locator('[data-feld="warenart"]').selectOption(ANFRAGE.warenart)
await form.locator('[data-feld="beschreibung"]').fill(ANFRAGE.beschreibung)
await form.locator('[data-feld="artikelzahl"]').selectOption(ANFRAGE.artikelzahl)
await form.locator('[data-feld="versand"]').check()
await form.locator('[data-feld="abholung"]').check()
await form.locator('[data-feld="unternehmer"]').check()
await form.locator('[data-feld="keineAusgeschlossenen"]').check()
await form.locator('[data-feld="nachricht"]').fill(ANFRAGE.nachricht)
await form.locator('[data-aktion="anfrage-senden"]').click()
const karte = page.locator('[data-anfrage="offen"]')
await karte.waitFor()
ok(enthaelt(await karte.innerText(), 'Anfrage wird geprüft'), 'Karte „Deine Anfrage wird geprüft“')
ok(enthaelt(await karte.innerText(), 'Anfrage zurückziehen'), 'Knopf „Anfrage zurückziehen“')
await shot(page, '03-anfrage-wird-geprueft')
ok(srv.shopDoc()?.anfrageStatus === 'offen', `shopConfig.anfrageStatus offen (ist ${srv.shopDoc()?.anfrageStatus})`)
await srv.warteAufVersand()
ok(srv.mails('Webshop').length >= 1, `Team-Mail zur Anfrage (T21) verschickt (${srv.mails().map((m) => m.subject).join(' / ')})`)
})
// ------------------------------------------------------------ 2
await lauf('2_annahme_bestellseite_zahlung_freischaltung', async () => {
const r = await srv.entscheide('annehmen')
ok(r.anfrage.status === 'angenommen', `Anfrage angenommen (${r.anfrage.status}), mailFehler ${r.mailFehler}`)
ok(srv.mails('Webshop').some((m) => m.to.some((t) => t.address === KUNDEN_EMAIL)), 'K24 an den Kunden')
await page.reload()
await webshopBereit(page)
const seite = page.locator('.tm-bestellen')
await seite.waitFor()
const haken = await seite.locator('input[data-haken]').count()
ok(haken === 5, `fünf Haken auf der Bestellseite (${haken})`)
const knopf = seite.locator('[data-aktion="bestellen"]')
ok(norm(await knopf.innerText()) === 'Zahlungspflichtig bestellen · 299 €', `Knopf „${norm(await knopf.innerText())}“`)
ok(await knopf.isDisabled(), 'Knopf gesperrt, solange Haken fehlen')
await shot(page, '04-bestellseite')
for (const h of await seite.locator('input[data-haken]').all()) await h.check()
ok(await knopf.isEnabled(), 'Knopf frei nach allen Haken')
await knopf.click()
await warteBis(() => stripeNavigationen.some((n) => n.art === 'checkout'), 'Navigation zur Stripe-Kasse')
const nav = stripeNavigationen.filter((n) => n.art === 'checkout').at(-1)
ok(/^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/.test(nav.url), `Weiterleitung zur Kasse (${nav.url})`)
ok(srv.featureDoc()?.state === 'ordered', `Funktion ordered (${srv.featureDoc()?.state})`)
ok(srv.aw.docs('featureConsents').size === 1, `Zustimmungsnachweis gespeichert (${srv.aw.docs('featureConsents').size})`)
// Zahlung: signierter Plattform-Webhook
const w = await srv.bezahleEinrichtung()
ok(w.status === 200, `Plattform-Webhook ${w.status} ${JSON.stringify(w.body)}`)
ok(srv.featureDoc()?.state === 'setup', `Funktion setup nach Zahlung (${srv.featureDoc()?.state})`)
ok(Boolean(srv.featureDoc()?.ticketId), 'Einrichtungs-Ticket angelegt')
ok(srv.siteKey() && /^[a-z2-7]{16}$/.test(srv.siteKey()), `siteKey ${srv.siteKey()}`)
// zurueck im Portal: „Danke!“ und Hinweis Einrichtung
await oeffne(page, `#webshop?bestellt=1&projekt=${srv.PROJEKT}`)
await page.reload()
await webshopBereit(page)
await page.locator('.sh-reiterleiste').waitFor()
ok(await page.locator('[data-hinweis="setup"]').count() === 1, 'Hinweis „Wir bauen deinen Shop gerade ein“')
await srv.schliesseTicket()
await page.reload()
await page.locator('.sh-reiterleiste').waitFor()
await warteBis(() => srv.featureDoc()?.state === 'active', 'Funktion active nach geschlossenem Ticket')
ok(srv.featureDoc()?.state === 'active', 'Funktion active')
await page.reload()
await page.locator('.sh-reiterleiste').waitFor()
ok(await page.locator('[data-hinweis="setup"]').count() === 0, 'Hinweis Einrichtung weg')
await shot(page, '05-shop-aktiv-uebersicht')
})
// ------------------------------------------------------------ 3a Einstellungen
await lauf('3a_einstellungen', async () => {
await reiter(page, 'einstellungen')
const form = page.getByRole('form', { name: 'Einstellungen des Webshops' })
await form.waitFor()
await shot(page, '06-einstellungen-leer')
const f = (n) => form.locator(`[data-feld="${n}"]`)
await f('shopname').fill('Töpferei Brandt')
await f('akzent').fill('#0f766e')
await f('anbieter.name').fill('Töpferei Brandt, Inh. Hanna Brandt')
await f('anbieter.strasse').fill('Hauptstraße 12')
await f('anbieter.plz').fill('67722')
await f('anbieter.ort').fill('Winnweiler')
await f('anbieter.email').fill(KUNDEN_EMAIL)
await f('anbieter.telefon').fill('06302 1234567')
await f('anbieter.ustId').fill('DE123456789')
await form.locator('input[name="sh-kleinunternehmer"][value="nein"]').check()
await f('versand.laender').selectOption('DE,AT')
await f('versand.kostenCent.DE').fill('4,90')
await f('versand.kostenCent.AT').fill('7,90')
await f('versand.freiAbCent').fill('80,00')
await f('abholung.aktiv').check()
await f('abholung.adresse').fill('Hauptstraße 12, 67722 Winnweiler')
await f('abholung.hinweis').fill('Di bis Fr 10 bis 17 Uhr')
await f('lieferzeit').fill('2–4 Werktage')
await shot(page, '08-einstellungen-ausgefuellt')
await form.locator('[data-aktion="einstellungen-speichern"]').click()
await warteMeldung(page, 'Die Einstellungen sind gespeichert')
const e = srv.einstellungen()
ok(e.kleinunternehmer === false, `Steuerart gespeichert: kleinunternehmer ${e.kleinunternehmer}`)
ok(e.anbieter?.ort === 'Winnweiler' && e.shopname === 'Töpferei Brandt', 'Einstellungen im Dokument')
ok(e.versand?.kostenCent?.AT === 790 && e.versand?.freiAbCent === 8000, `Versandkosten ${JSON.stringify(e.versand?.kostenCent)} frei ab ${e.versand?.freiAbCent}`)
ok(e.zahlungsarten?.includes('card'), 'Karte als Zahlungsart')
})
// ------------------------------------------------------------ 3b Rechtliches
await lauf('3b_rechtliches', async () => {
await reiter(page, 'rechtliches')
const form = page.getByRole('form', { name: 'Rechtstexte des Webshops' })
await form.waitFor()
await shot(page, '09-rechtliches-leer')
for (const key of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) await form.locator(`[data-aktion="muster-${key}"]`).click()
const platzhalter = []
for (const key of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) {
const feld = form.locator(`[data-text="${key}"]`)
const wert = await feld.inputValue()
ok(wert.length > 200, `Muster ${key} eingesetzt (${wert.length} Zeichen)`)
const treffer = wert.match(/\[[^\]]+\]/g) || []
for (const t of treffer) platzhalter.push(`${key}: ${t}`)
}
merk.platzhalter = platzhalter
// Platzhalter ersetzen und die Muster-Kopfzeile des AGB entfernen
const ersatz = (t) => (/datum|ort/i.test(t) ? 'Winnweiler' : /name|unterschrift/i.test(t) ? 'Hanna Brandt' : /anschrift|adresse/i.test(t) ? 'Hauptstraße 12, 67722 Winnweiler' : /mail/i.test(t) ? KUNDEN_EMAIL : /waren|dienstleistung|bestellt/i.test(t) ? 'Tasse Seladon' : 'Töpferei Brandt')
for (const key of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) {
const feld = form.locator(`[data-text="${key}"]`)
let wert = await feld.inputValue()
wert = wert.replace(/\[[^\]]+\]/g, ersatz).replace(/^Muster – bitte prüfen und anpassen\n*/m, '')
await feld.fill(wert)
}
await form.locator('[data-text="impressumUrl"]').fill('https://www.toepferei-brandt.example/impressum')
await form.locator('[data-text="datenschutzUrl"]').fill('https://www.toepferei-brandt.example/datenschutz')
// ungespeichert: „Erst speichern“
const bestaetigen = page.locator('[data-aktion="bestaetigen"]')
ok(norm(await bestaetigen.innerText()) === 'Erst speichern', `Knopf vor dem Speichern „${norm(await bestaetigen.innerText())}“`)
await form.locator('[data-aktion="entwurf-speichern"]').click()
await warteMeldung(page, 'Der Entwurf ist gespeichert')
ok(Boolean(srv.rechtstexte().entwurf?.agb), 'Entwurf im Dokument')
const probleme = await page.locator('[data-probleme] li').allInnerTexts()
ok(probleme.length === 0, `keine Probleme vor dem Bestaetigen (${probleme.join(' / ')})`)
ok(await page.locator('[data-lesetext="gewaehrleistung-bild"]').count() === 1, 'Gewährleistungs-Bild im Reiter')
await shot(page, '10-rechtliches-entwurf')
await page.locator('[data-feld="bestaetigung"]').check()
ok(norm(await bestaetigen.innerText()) === 'Texte bestätigen' && await bestaetigen.isEnabled(), 'Knopf „Texte bestätigen“ frei')
await bestaetigen.click()
await warteMeldung(page, 'Die Texte sind bestätigt')
ok(Boolean(srv.rechtstexte().bestaetigt?.hash), 'Bestätigung gespeichert')
ok(srv.aw.docs('shopRechtstexteArchiv').size === 1, `Archiv der bestätigten Fassung (${srv.aw.docs('shopRechtstexteArchiv').size})`)
ok(await page.locator('[data-status="bestaetigt"]').count() === 1, 'Status „Bestätigt“ sichtbar')
await shot(page, '11-rechtliches-bestaetigt')
})
// ------------------------------------------------------------ 3c Artikel
await lauf('3c_artikel', async () => {
await reiter(page, 'artikel')
await page.locator('[data-aktion="artikel-neu"]').click()
const form = page.getByRole('form', { name: 'Artikel anlegen' })
await form.waitFor()
const f = (n) => form.locator(`[data-feld="${n}"]`)
// Artikel 1: Tasse mit Bild
await f('name').fill('Tasse Seladon')
await f('kategorie').fill('Geschirr')
await f('kurz').fill('Handgedrehte Tasse mit seladongrüner Glasur.')
await f('beschreibung').fill('Fasst etwa 300 ml. Spülmaschinengeeignet. Jedes Stück ist ein Unikat.')
await f('preisCent').fill('18,50')
await f('bestand').fill('12')
const png = await sharp({ create: { width: 800, height: 800, channels: 3, background: { r: 120, g: 170, b: 150 } } })
.composite([{ input: Buffer.from('<svg width="800" height="800"><circle cx="400" cy="400" r="260" fill="#f4efe6"/><circle cx="400" cy="400" r="150" fill="#2f6f5e"/></svg>'), top: 0, left: 0 }])
.png().toBuffer()
await form.locator('input[type="file"]').setInputFiles({ name: 'tasse.png', mimeType: 'image/png', buffer: png })
await page.locator('li[data-bild]').waitFor()
ok(await page.locator('li[data-bild] img').evaluate((img) => img.complete && img.naturalWidth > 0), 'Bild hochgeladen, Vorschau geladen')
await shot(page, '12-artikel-editor-tasse', { telefon: false })
await speichereArtikel(page, form, 1)
// Artikel 2: Vase mit Varianten
await page.locator('[data-aktion="artikel-neu"]').click()
const form2 = page.getByRole('form', { name: 'Artikel anlegen' })
await form2.waitFor()
const g = (n) => form2.locator(`[data-feld="${n}"]`)
await g('name').fill('Vase Wabi')
await g('kategorie').fill('Vasen')
await g('kurz').fill('Bauchige Vase in drei Größen.')
await g('beschreibung').fill('Aus Steinzeug, innen glasiert, wasserdicht.')
await g('variantenAn').check()
await g('varianten.name').fill('Größe')
const werte = [['Klein', '24,00', '5'], ['Mittel', '36,00', '3'], ['Groß', '52,00', '2']]
for (let i = 0; i < werte.length; i += 1) {
if (i > 0) await form2.locator('[data-aktion="wert-neu"]').click()
await g(`varianten.werte.${i}.name`).fill(werte[i][0])
await g(`varianten.werte.${i}.preisCent`).fill(werte[i][1])
await g(`varianten.werte.${i}.bestand`).fill(werte[i][2])
}
await shot(page, '13-artikel-editor-vase', { telefon: false })
await speichereArtikel(page, form2, 2)
const ps = srv.produkte()
ok(ps.length === 2, `zwei Artikel gespeichert (${ps.length})`)
const tasse = ps.find((p) => p.name === 'Tasse Seladon')
const vase = ps.find((p) => p.name === 'Vase Wabi')
ok(tasse?.produkt.preisCent === 1850 && tasse?.produkt.bilder?.length === 1, `Tasse: ${tasse?.produkt.preisCent} Cent, ${tasse?.produkt.bilder?.length} Bild`)
ok(vase?.produkt.varianten?.werte?.length === 3, `Vase: ${vase?.produkt.varianten?.werte?.length} Varianten`)
ok(await page.locator('li[data-artikel]').count() === 2, 'zwei Artikel in der Liste')
await shot(page, '14-artikel-liste')
})
// ------------------------------------------------------------ 3d Uebersicht, Stripe, Shop oeffnen
await lauf('3d_uebersicht_stripe_oeffnen', async () => {
await reiter(page, 'uebersicht')
await page.locator('[data-liste="bereitschaft"]').waitFor()
ok(await page.locator('li[data-ok="0"]').count() > 0, 'Checkliste: noch offene Punkte vor Stripe')
const oeffnen = page.locator('[data-aktion="oeffnen"]')
ok(await oeffnen.isDisabled(), '„Shop öffnen“ gesperrt')
await shot(page, '15-uebersicht-vor-stripe')
const vorher = stripeNavigationen.length
await page.locator('[data-aktion="stripe-einrichten"]').click()
await warteBis(() => stripeNavigationen.length > vorher && stripeNavigationen.at(-1).art === 'connect', 'Weiterleitung zum Stripe-Onboarding')
ok(/^https:\/\/connect\.stripe\.test\/setup\/acct_/.test(stripeNavigationen.at(-1).url), `Onboarding-Link ${stripeNavigationen.at(-1).url}`)
// zurueck ins Portal (Platzhalterseite leitet zu #webshop?stripe=zurueck&projekt=…)
await page.waitForURL(/dashboard\.html/)
await page.locator('.sh-reiterleiste').waitFor()
await page.locator('[data-liste="bereitschaft"]').waitFor()
await warteMeldung(page, 'Willkommen zurück von Stripe')
ok(!page.url().includes('stripe=zurueck'), `Rückkehr-Parameter aus der Adresse entfernt (${page.url()})`)
ok(await page.locator('[data-stripe="zahlungen"]').innerText().then((t) => enthaelt(t, 'noch nicht freigeschaltet')), 'Stripe: Zahlungen noch nicht freigeschaltet')
ok(srv.kontoId()?.startsWith('acct_'), `Konto angelegt (${srv.kontoId()})`)
await shot(page, '16-uebersicht-nach-rueckkehr-stripe')
srv.kontoFreigeben()
await page.locator('[data-aktion="stripe-aktualisieren"]').click()
await warteMeldung(page, 'aktuell')
await warteBis(async () => (await page.locator('li[data-ok="0"]').count()) === 0, 'alle Punkte der Checkliste erledigt')
ok(await page.locator('[data-stripe="zahlungen"]').innerText().then((t) => enthaelt(t, 'Zahlungen: aktiv')), 'Stripe: Zahlungen aktiv')
ok(await oeffnen.isEnabled(), '„Shop öffnen“ frei')
await shot(page, '17-uebersicht-bereit')
await oeffnen.click()
await warteBis(async () => (await page.locator('[data-zustand="offen"]').count()) === 1, 'Zustand „Dein Shop ist geöffnet“')
ok(srv.shopDoc()?.geoeffnet === true, 'geoeffnet im Dokument')
ok(await page.locator('[data-aktion="schliessen"]').count() === 1, 'Knopf „Shop schließen“')
await shot(page, '18-uebersicht-geoeffnet')
})
}
// ---------------------------------------------------------------- Kaeufer, Mails, Haendler, Widerruf
async function kaeuferSchritte() {
const key = srv.siteKey()
const ctxK = await neuerKontext(browser, { cookie: false })
const kaeufer = await ctxK.newPage()
// ------------------------------------------------------------ 4a Standalone
await lauf('4a_kaeufer_seite_raster_detail_korb', async () => {
await kaeufer.goto(`${srv.portalUrl}/shop/${key}`)
await kaeufer.locator('.wks-raster li').first().waitFor()
ok(await kaeufer.locator('.wks-raster li').count() === 2, 'zwei Artikel im Raster')
ok(enthaelt(await kaeufer.locator('.wks-shopname').innerText(), 'Töpferei Brandt'), 'Shopname im Kopf')
ok(await kaeufer.locator('.wks-karte-bild').first().evaluate((img) => img.complete && img.naturalWidth > 0), 'Artikelbild der Tasse geladen')
ok(enthaelt(await kaeufer.locator('.wks-raster').innerText(), 'ab 24,00 €'), 'Vase „ab 24,00 €“')
ok(enthaelt(await kaeufer.locator('.wks-hinweiszeile').first().innerText(), 'Alle Preise inkl. MwSt.'), 'Steuerhinweis')
ok(await kaeufer.locator('.wks-preis-versand button').count() === 2, 'Versandhinweis „zzgl. Versand“ bei jedem Preis')
ok(await passtInBreite(kaeufer), 'Seite passt in die Breite (Desktop)')
await shot(kaeufer, '20-shop-raster')
await kaeufer.getByRole('button', { name: 'Tasse Seladon' }).first().click()
await kaeufer.locator('.wks-detail').waitFor()
ok(enthaelt(await kaeufer.locator('.wks-info').innerText(), '18,50 €'), 'Detail: Preis 18,50 €')
await shot(kaeufer, '21-shop-detail')
await kaeufer.getByRole('button', { name: 'In den Warenkorb' }).click()
await kaeufer.locator('.wks-info .wks-meldung:not([hidden])').waitFor()
await kaeufer.getByRole('button', { name: 'Weiter einkaufen' }).click()
await kaeufer.getByRole('button', { name: 'Vase Wabi' }).first().click()
await kaeufer.locator('.wks-detail').waitFor()
await kaeufer.getByRole('button', { name: 'In den Warenkorb' }).click()
ok(enthaelt(await kaeufer.locator('.wks-info').innerText(), 'Bitte wählen Sie eine Variante'), 'Ohne Variante: Hinweis „Bitte wählen Sie eine Variante“')
await kaeufer.locator('select.wks-eingabe').selectOption({ label: 'Mittel – 36,00 €' })
ok(enthaelt(await kaeufer.locator('.wks-info .wks-preis').innerText(), '36,00 €'), 'Variantenpreis 36,00 €')
await shot(kaeufer, '22-shop-detail-variante', { telefon: false })
await kaeufer.getByRole('button', { name: 'In den Warenkorb' }).click()
await kaeufer.locator('.wks-info .wks-meldung:not([hidden])').waitFor()
await kaeufer.getByRole('button', { name: 'Zum Warenkorb' }).click()
await kaeufer.locator('.wks-zeilen li').first().waitFor()
ok(await kaeufer.locator('.wks-zeilen li').count() === 2, 'zwei Positionen im Warenkorb')
await shot(kaeufer, '23-shop-warenkorb')
})
// ------------------------------------------------------------ 4b Kasse
await lauf('4b_kasse_pflichtangaben_und_bestellung', async () => {
await kaeufer.getByRole('button', { name: 'Zur Kasse' }).click()
await kaeufer.locator('.wks-pflicht .wks-summen').waitFor()
const pflicht = await kaeufer.locator('.wks-pflicht').innerText()
// § 312j BGB: wesentliche Merkmale, Gesamtpreis, Lieferung, Widerruf direkt vor dem Knopf
for (const teil of ['Ihre Bestellung', '1 × Tasse Seladon', '1 × Vase Wabi (Größe: Mittel)', 'Zwischensumme', 'Versandkosten', 'Gesamtsumme', 'Lieferung und Zahlung', 'Lieferzeit', '2–4 Werktage', 'Zahlungsarten', 'Anbieter', 'Töpferei Brandt', 'Widerrufsrecht', 'Sie haben ein gesetzliches Widerrufsrecht', 'Name, Lieferadresse und Zahlung geben Sie im nächsten Schritt bei unserem Zahlungsdienstleister Stripe ein']) {
if (!ok(enthaelt(pflicht, teil), `§ 312j-Block enthält „${teil}“`) && !merk.pflichtGezeigt) { merk.pflichtGezeigt = true; console.log(` Pflichtblock: ${norm(pflicht)}`) }
}
ok(enthaelt(await kaeufer.locator('.wks-summen').innerText(), '54,50 €'), `Zwischensumme 54,50 € (${norm(await kaeufer.locator('.wks-summen').innerText())})`)
ok(enthaelt(await kaeufer.locator('.wks-summen').innerText(), '59,40 €'), 'Gesamtsumme 59,40 € (inkl. 4,90 € Versand)')
// Gewaehrleistungshinweis mit der amtlichen Grafik
const gk = kaeufer.locator('.wks-gewaehr')
ok(await gk.count() === 1, 'Gewährleistungskasten vorhanden')
const img = gk.locator('img')
ok((await img.getAttribute('src'))?.endsWith('/img/gewaehrleistung-eu.webp'), `Gewährleistungsbild ${await img.getAttribute('src')}`)
ok(await img.evaluate((i) => i.complete && i.naturalWidth > 0), 'Gewährleistungsbild geladen')
ok(enthaelt((await img.getAttribute('alt')) || '', 'Gewährleistung'), 'Alternativtext des Bildes enthält den Hinweistext')
ok(enthaelt(await gk.innerText(), 'Ihre gesetzlichen Gewährleistungsrechte'), 'Überschrift des Kastens')
// AGB-Satz
const agb = norm(await kaeufer.locator('.wks-agb-hinweis').innerText())
ok(agb === 'Es gelten unsere AGB. Informationen zu Ihrem Widerrufsrecht finden Sie in der Widerrufsbelehrung.', `AGB-Satz „${agb}“`)
// Knopf
const senden = kaeufer.locator('form.wks-form button[type="submit"]')
ok(norm(await senden.innerText()) === 'Zahlungspflichtig bestellen', `Knopf „${norm(await senden.innerText())}“`)
ok(await senden.isEnabled(), 'Knopf frei')
// Reihenfolge: Gewaehrleistung vor Zusammenfassung, AGB-Satz und Knopf am Ende
const reihenfolge = await kaeufer.locator('form.wks-form').evaluate((f) => {
const pos = (sel) => { const e = f.querySelector(sel); return e ? [...f.querySelectorAll('*')].indexOf(e) : -1 }
return [pos('.wks-gewaehr'), pos('.wks-pflicht'), pos('.wks-agb-hinweis'), pos('button[type="submit"]')]
})
ok(reihenfolge.every((p, i, a) => p > 0 && (i === 0 || p > a[i - 1])), `Reihenfolge Gewährleistung < Pflichtangaben < AGB-Satz < Knopf (${reihenfolge})`)
await shot(kaeufer, '24-shop-kasse')
// AGB-Dialog
await kaeufer.locator('.wks-agb-hinweis').getByRole('button', { name: 'AGB' }).click()
await kaeufer.locator('dialog[open] .wks-dialog-text').waitFor()
ok(enthaelt(await kaeufer.locator('dialog[open] .wks-dialog-text').innerText(), 'Töpferei Brandt'), 'AGB im Dialog (ersetzte Platzhalter)')
await shot(kaeufer, '25-shop-agb-dialog', { telefon: false })
await kaeufer.locator('dialog[open] .wks-nav').getByRole('button', { name: 'Schließen' }).click()
// bestellen -> Stripe
const vorher = stripeNavigationen.length
await senden.click()
await warteBis(() => stripeNavigationen.length > vorher, 'Weiterleitung zur Stripe-Kasse des Händlers', 15000)
const nav = stripeNavigationen.at(-1)
ok(nav.art === 'checkout' && /\/c\/pay\/cs_/.test(nav.url), `Weiterleitung ${nav.url}`)
const bestellung = srv.bestellungen().at(-1)
ok(bestellung?.status === 'offen', `Bestellung offen (${bestellung?.status})`)
ok(bestellung?.summeCent === 5940, `Bestellsumme ${bestellung?.summeCent} Cent`)
merk.sitzung = bestellung?.stripeSessionId
merk.bestellnummer = bestellung?.bestellnummer
const sitzung = srv.kassenSitzungen().find((s) => s.id === merk.sitzung)
ok(Boolean(sitzung?.success_url?.includes('wk_bestellung=')), `success_url trägt wk_bestellung (${sitzung?.success_url})`)
merk.erfolgUrl = sitzung?.success_url
// Zahlung beim Haendlerkonto, signierter Connect-Webhook
const bezahlt = srv.bezahleKasse(merk.sitzung)
const w = await srv.sendeConnect('checkout.session.completed', bezahlt)
ok(w.status === 200, `Connect-Webhook ${w.status} ${JSON.stringify(w.body)}`)
const nach = srv.bestellungen().find((o) => o.stripeSessionId === merk.sitzung)
ok(nach?.status === 'bezahlt', `Bestellung bezahlt (${nach?.status})`)
const ptasse = srv.produkte().find((p) => p.name === 'Tasse Seladon')
ok(ptasse?.produkt.bestand === 11, `Bestand Tasse 12 -> ${ptasse?.produkt.bestand}`)
const wd = await srv.sendeConnect('checkout.session.completed', bezahlt)
ok(wd.status === 200, 'Wiederholung des Webhooks bleibt 200')
ok(srv.bestellungen().length === 1 && srv.produkte().find((p) => p.name === 'Tasse Seladon').produkt.bestand === 11, 'Wiederholung ändert nichts')
// Rueckkehr
await kaeufer.goto(merk.erfolgUrl)
await kaeufer.locator('.wks-statusbox .wks-ergebnis-text').waitFor()
await warteBis(async () => enthaelt(await kaeufer.locator('.wks-statusbox').innerText(), 'Ihre Zahlung ist eingegangen'), 'Status „Zahlung eingegangen“')
const st = norm(await kaeufer.locator('.wks-statusbox').innerText())
ok(st.includes(`Vielen Dank für Ihre Bestellung ${merk.bestellnummer}!`), `Dank mit Bestellnummer (${st})`)
ok(st.includes('Bestellnummer') && st.includes('59,40 €'), 'Statusbox: Bestellnummer und Gesamtsumme')
ok(st.includes('m***@example.com'), 'E-Mail verdeckt angezeigt')
ok(!kaeufer.url().includes('wk_t='), `Token aus der Adresse entfernt (${kaeufer.url()})`)
await shot(kaeufer, '26-shop-danke')
})
// ------------------------------------------------------------ 4c Demo-Seite (data-api, Cross-Origin)
await lauf('4c_demo_seite_cross_origin', async () => {
const demo = await ctxK.newPage()
await demo.goto(`${srv.kundenUrl}/shop`)
await demo.locator('.wks-raster li').first().waitFor()
ok(new URL(demo.url()).origin === srv.kundenUrl, `Seite kommt von ${new URL(demo.url()).origin}, API von ${srv.portalUrl}`)
ok(await demo.locator('.wks-raster li').count() === 2, 'Demo: zwei Artikel (GET Cross-Origin)')
ok(await demo.locator('.fallback').count() === 0, 'Ausweichtext ersetzt')
ok(await demo.locator('.wks-karte-bild').first().evaluate((img) => img.complete && img.naturalWidth > 0), 'Demo: Artikelbild über data-api geladen')
await shot(demo, '27-demo-seite-raster')
await demo.getByRole('button', { name: 'Tasse Seladon' }).first().click()
await demo.getByRole('button', { name: 'In den Warenkorb' }).click()
await demo.getByRole('button', { name: 'Zum Warenkorb' }).click()
await demo.getByRole('button', { name: 'Zur Kasse' }).click()
await demo.locator('.wks-pflicht .wks-summen').waitFor() // POST /pruefen Cross-Origin (Preflight)
ok(enthaelt(await demo.locator('.wks-summen').innerText(), '23,40 €'), 'Demo: Zusammenfassung vom Server (18,50 € + 4,90 € Versand)')
ok(await demo.locator('.wks-gewaehr img').evaluate((i) => i.complete && i.naturalWidth > 0), 'Demo: Gewährleistungsbild geladen')
const vorher = stripeNavigationen.length
await demo.locator('form.wks-form button[type="submit"]').click()
await warteBis(() => stripeNavigationen.length > vorher, 'Demo: Weiterleitung zur Kasse (POST Cross-Origin)', 15000)
ok(stripeNavigationen.at(-1).art === 'checkout', 'Demo: Bestellung angelegt (Cross-Origin-POST erlaubt)')
const letzte = srv.bestellungen().find((o) => o.status === 'offen')
ok(Boolean(letzte), 'Demo: zweite Bestellung offen (bleibt unbezahlt)')
const s = srv.kassenSitzungen().find((x) => x.id === letzte?.stripeSessionId)
ok(Boolean(s?.success_url?.startsWith(`${srv.kundenUrl}/shop`)), `Demo: Rücksprung auf die Seite des Händlers (${s?.success_url})`)
await demo.close()
})
// ------------------------------------------------------------ 5 Mails
await lauf('5_mails_g09_und_k25', async () => {
await srv.warteAufVersand()
const alle = srv.mails()
console.log(` Mails: ${alle.map((m) => `${m.to.map((t) => t.address).join(',')}: ${m.subject}`).join(' | ')}`)
const g09 = alle.find((m) => m.to.some((t) => t.address === 'max@example.com') && enthaelt(m.text || '', 'Mit dieser E-Mail nehmen wir Ihre Bestellung an'))
ok(Boolean(g09), 'G09 an den Käufer mit „Mit dieser E-Mail nehmen wir Ihre Bestellung an“')
if (g09) {
ok(enthaelt(g09.subject, merk.bestellnummer), `G09-Betreff nennt die Bestellnummer (${g09.subject})`)
ok(enthaelt(g09.text, 'Töpferei Brandt'), 'G09 enthält Shopname/AGB des Händlers')
ok(enthaelt(g09.text, 'Widerruf'), 'G09 enthält die Widerrufsbelehrung')
ok(enthaelt(g09.text, '1 × Tasse Seladon'), 'G09 listet die Positionen')
ok(!(g09.bcc?.length), `G09 ohne BCC (${JSON.stringify(g09.bcc)})`)
ok(g09.replyTo?.some?.((r) => r.address === KUNDEN_EMAIL) || g09.replyTo?.address === KUNDEN_EMAIL || /toepferei|Töpferei/i.test(g09.from?.name || ''), `Absendername/Antwort an den Händler (${JSON.stringify(g09.from)} ${JSON.stringify(g09.replyTo)})`)
ok((g09.attachments || []).length >= 0, 'Anhänge geprüft')
}
const k25 = alle.find((m) => m.to.some((t) => t.address === KUNDEN_EMAIL) && /^Neue Bestellung \d\d-/.test(m.subject))
ok(Boolean(k25), `K25 an den Händler (${alle.filter((m) => m.to.some((t) => t.address === KUNDEN_EMAIL)).map((m) => m.subject).join(' / ')})`)
if (k25) {
ok(!(k25.bcc?.length), `K25 ohne BCC (${JSON.stringify(k25.bcc)})`)
ok(enthaelt(`${k25.subject} ${k25.text}`, merk.bestellnummer), 'K25 nennt die Bestellnummer')
ok(enthaelt(k25.text, 'max@example.com') || enthaelt(k25.text, 'Max Mustermann'), 'K25 nennt den Käufer')
}
// Die Warteschlange ist abgearbeitet
const offen = srv.outbox().filter((e) => e.status && !['sent', 'gesendet', 'done'].includes(e.status))
ok(offen.length === 0, `Outbox ohne offene Einträge (${offen.map((e) => `${e.art}:${e.status}`).join(',')})`)
})
// ------------------------------------------------------------ 6 Haendler: versenden, erstatten
await lauf('6_haendler_bestellung_versendet_erstattung', async () => {
const page = portalPage
await oeffne(page, '#webshop')
await page.reload()
await webshopBereit(page)
await reiter(page, 'bestellungen')
const zeile = page.locator(`[data-bestellung]`).first()
await zeile.waitFor()
ok(enthaelt(await zeile.innerText(), merk.bestellnummer), `Bestellung ${merk.bestellnummer} in der Liste`)
ok(enthaelt(await zeile.innerText(), '59,40 €') && enthaelt(await zeile.innerText(), 'Bezahlt'), 'Zeile: 59,40 €, Status Bezahlt')
await shot(page, '30-bestellungen-liste')
await zeile.click()
await page.locator('[data-aktion="versendet"]').waitFor()
const detail = norm(await page.locator('.sh-bestellungen-inhalt').innerText())
for (const teil of ['Max Mustermann', 'max@example.com', 'Lieferweg 7', '67655 Kaiserslautern', '1 × Tasse Seladon', '1 × Vase Wabi (Größe: Mittel)', 'Karte']) ok(detail.includes(teil), `Detail zeigt „${teil}“`)
await shot(page, '31-bestellung-detail')
await page.locator('[data-aktion="versendet"]').click()
const f = page.getByRole('form', { name: 'Als versendet markieren' })
await f.locator('[data-feld="versanddienst"]').fill('DHL')
await f.locator('[data-feld="sendungsnummer"]').fill('00340434161094042557')
await f.locator('[data-aktion="versendet-speichern"]').click()
await warteMeldung(page, 'Als versendet gespeichert')
ok(srv.bestellungen()[0].status === 'versendet', `Status versendet (${srv.bestellungen()[0].status})`)
ok(/DHL, 00340434161094042557/.test(await page.locator('.sh-bestellungen-inhalt').innerText()), 'Detail zeigt Versanddienst und Sendungsnummer')
await srv.warteAufVersand()
const g10 = srv.mails().find((m) => m.to.some((t) => t.address === 'max@example.com') && m.text.includes('00340434161094042557'))
ok(Boolean(g10), 'G10 an den Käufer mit der Sendungsnummer')
if (g10) ok(!(g10.bcc?.length), 'G10 ohne BCC')
await shot(page, '32-bestellung-versendet')
// Erstatten (teilweise)
const betrag = page.locator('#sh-erstatten-betrag')
ok(await betrag.inputValue() === '59,40', `Vorbelegung des Erstattungsbetrags ${await betrag.inputValue()}`)
await betrag.fill('1,00')
await page.locator('[data-aktion="erstatten"]').click()
await page.locator('[data-aktion="erstatten-bestaetigen"]').waitFor()
await shot(page, '33-erstattung-rueckfrage', { telefon: false })
await page.locator('[data-aktion="erstatten-bestaetigen"]').click()
await warteMeldung(page, 'erstattet')
ok(enthaelt(await echteMeldungen(page), '1,00 € erstattet'), `Meldung „${await echteMeldungen(page)}“`)
const acct = srv.kontoId()
const refundCall = st().calls.find((c) => c.method === 'POST' && c.path === '/v1/refunds')
ok(Boolean(refundCall), 'Stripe-Aufruf POST /v1/refunds')
ok(refundCall?.account === acct, `Erstattung mit Stripe-Account-Kopfzeile ${refundCall?.account} (Konto ${acct})`)
ok(refundCall?.body?.amount === '100' && refundCall?.body?.payment_intent === 'pi_ui_1', `Betrag ${refundCall?.body?.amount}, Zahlung ${refundCall?.body?.payment_intent}`)
const o = srv.bestellungen()[0]
ok(o.status === 'versendet' && /teilerstattet/.test(JSON.stringify(o.daten.verlauf)), 'Teilerstattung im Verlauf, Status bleibt versendet')
await srv.warteAufVersand()
const g13 = srv.mails().find((m) => m.to.some((t) => t.address === 'max@example.com') && /Erstattung|erstattet/i.test(`${m.subject} ${m.text}`) && /1,00/.test(m.text))
ok(Boolean(g13), 'G13 an den Käufer (1,00 € erstattet)')
await shot(page, '34-erstattung-erledigt')
})
// ------------------------------------------------------------ 7 Widerruf
await lauf('7_widerruf', async () => {
const w = await ctxK.newPage()
await w.goto(`${srv.portalUrl}/shop/${key}/widerruf`)
await w.locator('form.wks-form').waitFor()
ok(enthaelt(await w.locator('.wks-titel').innerText(), 'Vertrag widerrufen'), 'Titel „Vertrag widerrufen“')
await shot(w, '40-widerruf-formular')
// Pflichtfelder
await w.getByRole('button', { name: 'Weiter' }).click()
await w.locator('.wks-feldfehler:not([hidden])').first().waitFor()
ok(await w.locator('.wks-feldfehler:not([hidden])').count() >= 2, 'Leeres Formular: Feldfehler')
await w.getByLabel('Ihr Name').fill('Max Mustermann')
await w.getByLabel('E-Mail-Adresse der Bestellung').fill('max@example.com')
await w.getByLabel('Bestellnummer (falls zur Hand)').fill(merk.bestellnummer)
await w.getByLabel('Erklärung').fill('Ich widerrufe die Vase.')
await w.getByRole('button', { name: 'Weiter' }).click()
await w.getByRole('button', { name: 'Widerruf bestätigen' }).waitFor()
ok(enthaelt(await w.locator('.wks-zusammen').innerText(), merk.bestellnummer), 'Prüfseite nennt die Bestellnummer')
await shot(w, '41-widerruf-pruefen')
await w.getByRole('button', { name: 'Widerruf bestätigen' }).click()
await w.locator('.wks-ergebnis-text').waitFor({ timeout: 15000 })
const quittung = norm(await w.locator('.wks-ergebnis-text').innerText())
ok(/Ihr Widerruf ist am \d{1,2}\.\d{1,2}\.\d{4}.*\d{1,2}:\d{2}/.test(quittung), `Quittung mit Datum und Uhrzeit („${quittung}“)`)
await shot(w, '42-widerruf-quittung')
ok(srv.widerrufe().length === 1, `Widerruf gespeichert (${srv.widerrufe().length})`)
ok(Boolean(srv.widerrufe()[0]?.orderId), 'Widerruf der Bestellung zugeordnet')
await srv.warteAufVersand()
const g12 = srv.mails().find((m) => m.to.some((t) => t.address === 'max@example.com') && /Widerruf/.test(m.subject))
ok(Boolean(g12), 'G12 (Eingangsbestätigung) an den Käufer')
if (g12) ok(/\d{2}:\d{2}/.test(g12.text) && /\d{4}/.test(g12.text), 'G12 nennt Datum und Uhrzeit')
const k26 = srv.mails().find((m) => m.to.some((t) => t.address === KUNDEN_EMAIL) && /Widerruf/.test(m.subject))
ok(Boolean(k26), `K26 an den Händler (${srv.mails().map((m) => m.subject).filter((s) => /Widerruf/.test(s)).join(' / ')})`)
if (k26) ok(!(k26.bcc?.length), 'K26 ohne BCC')
// Haendler sieht den Widerruf
const page = portalPage
await page.reload()
await webshopBereit(page)
await reiter(page, 'bestellungen')
await page.locator('[data-ansicht="widerrufe"]').click()
await page.locator('[data-widerruf]').waitFor()
ok(enthaelt(await page.locator('[data-widerruf]').innerText(), 'Max Mustermann'), 'Widerruf im Reiter Bestellungen')
await shot(page, '43-haendler-widerrufe')
await page.locator('[data-aktion="erledigt"]').click()
await warteMeldung(page, 'als erledigt markiert')
ok(Boolean(srv.widerrufe()[0]?.bearbeitetAt), 'Widerruf als erledigt gespeichert')
await w.close()
})
// ------------------------------------------------------------ 8 Shop schliessen
await lauf('8_shop_schliessen_widerruf_bleibt', async () => {
const page = portalPage
await page.reload()
await webshopBereit(page)
await reiter(page, 'uebersicht')
await page.locator('[data-aktion="schliessen"]').click()
await warteMeldung(page, 'Dein Shop ist jetzt geschlossen')
ok(srv.shopDoc()?.geoeffnet === false, 'geoeffnet false im Dokument')
ok(await page.locator('[data-zustand="zu"]').count() === 1, 'Zustand „Dein Shop ist geschlossen“')
await shot(page, '50-uebersicht-geschlossen')
const k = await ctxK.newPage()
await k.goto(`${srv.portalUrl}/shop/${key}`)
await k.locator('.wks-titel').waitFor()
ok(enthaelt(await k.locator('.wks-titel').innerText(), 'Shop geschlossen'), 'Widget: „Shop geschlossen“')
ok(enthaelt(await k.locator('.wks-body').innerText(), 'Der Shop ist gerade geschlossen.'), 'Widget: Text „Der Shop ist gerade geschlossen.“')
ok(await k.locator('.wks-raster').count() === 0 && await k.getByRole('button', { name: /Warenkorb/ }).count() === 0, 'kein Raster, kein Warenkorb')
const widerrufKnopf = k.locator('.wks-fuss').getByRole('button', { name: 'Vertrag widerrufen' })
ok(await widerrufKnopf.count() === 1, 'Fußzeile: „Vertrag widerrufen“ bleibt')
ok(await k.locator('.wks-fuss').getByRole('button', { name: 'AGB' }).count() === 1, 'Fußzeile: AGB bleibt')
await shot(k, '51-widget-geschlossen')
await widerrufKnopf.click()
await k.locator('form.wks-form').waitFor()
ok(enthaelt(await k.locator('.wks-titel').innerText(), 'Vertrag widerrufen'), 'Widerrufsformular im geschlossenen Shop erreichbar')
// direkte Adresse
await k.goto(`${srv.portalUrl}/shop/${key}/widerruf`)
await k.locator('form.wks-form').waitFor()
ok(true, '/widerruf-Adresse erreichbar')
// Widerruf im geschlossenen Shop wird weiter angenommen
await k.getByLabel('Ihr Name').fill('Erika Muster')
await k.getByLabel('E-Mail-Adresse der Bestellung').fill('erika@example.com')
await k.getByLabel('Erklärung').fill('Bestellt am 05.10.2026: Tasse Seladon.')
await k.getByRole('button', { name: 'Weiter' }).click()
await k.getByRole('button', { name: 'Widerruf bestätigen' }).click()
await k.locator('.wks-ergebnis-text').waitFor({ timeout: 15000 })
ok(srv.widerrufe().length === 2, `zweiter Widerruf im geschlossenen Shop gespeichert (${srv.widerrufe().length})`)
// POST /pruefen und /bestellung im geschlossenen Shop: 409
const key2 = srv.siteKey()
const r = await fetch(`${srv.url}/api/public/shop/v1/${key2}/pruefen`, { method: 'POST', headers: { 'content-type': 'application/json', origin: srv.portalUrl }, body: JSON.stringify({ positionen: [{ produktId: srv.produkte()[0].$id, menge: 1 }], lieferart: 'versand', land: 'DE' }) })
ok(r.status === 409, `POST /pruefen im geschlossenen Shop: ${r.status}`)
await k.close()
})
await ctxK.close()
}
const st = () => srv.st
// ---------------------------------------------------------------- Lauf
let abbruch = null
try {
browser = await chromium.launch({ executablePath: CHROME })
await szenario()
if (srv.siteKey()) await kaeuferSchritte()
const eigene = new Set([srv.url, srv.portalUrl, srv.kundenUrl].map((u) => new URL(u).host))
const fremd = [...fremdeHosts].filter((h) => !eigene.has(h))
ergebnisse.push(['nur_eigene_herkunft', fremd.length === 0, fremd.length ? [`Anfragen an fremde Hosts: ${fremd.join(', ')}`] : [], []])
console.log(fremd.length ? `FAIL nur_eigene_herkunft: ${fremd.join(', ')}` : 'ok nur_eigene_herkunft')
} catch (e) {
abbruch = e
console.log(`ABBRUCH: ${e.stack || e.message}`)
} finally {
if (browser) await browser.close().catch(() => {})
await srv.stop().catch((e) => console.log(`Server beenden: ${e.message}`))
}
const gescheitert = ergebnisse.filter(([, bestanden]) => !bestanden)
console.log(`\n${ergebnisse.length - gescheitert.length}/${ergebnisse.length} Schritte bestanden`)
for (const [name, , fehler] of gescheitert) console.log(` FAIL ${name}\n${fehler.map((f) => ` - ${f}`).join('\n')}`)
process.exit(abbruch || gescheitert.length ? 1 : 0)
+289
View File
@@ -0,0 +1,289 @@
// UI-Testserver des Webshops (fuer test/ui/shop.ui.mjs): die echten Express-Router (/api/public/shop/v1, /api/shop/stripe-webhook,
// /api/shop, /api/funktionen, /api/abo), die Seiten /shop/:siteKey und /rechtliches/ sowie public/ als statische Dateien, dahinter der
// Nachbau von Appwrite und Stripe (mit Connect) aus test/support/ und eine Mail-Senke. Nichts verlaesst den Rechner (useTestEnv erlaubt nur
// 127.0.0.1). Muster: test/ui/termineTestServer.mjs.
//
// Aufruf in einem Skript (KEINE Server-Module vorher importieren, config.js liest die Umgebung beim ersten Import):
// import { startShopUiServer } from './shopTestServer.mjs'
// const srv = await startShopUiServer()
// ... srv.url, srv.portalUrl, srv.kundenUrl, srv.sink.sent, srv.st (Stripe-Nachbau), srv.aw (Appwrite-Nachbau) ...
// await srv.stop() // erst den Browser schliessen, dann den Server
//
// Uhr: echte Uhr (Server und Browser). Zeitabhaengige Pruefungen lesen die Zeit aus der Antwort.
//
// Mitarbeiter-Schritte rufen die Dienste direkt auf (die Team-Routen brauchen ein Mitarbeiter-JWT):
// srv.entscheide('annehmen' | 'ablehnen', begruendung) entscheideAnfrage
// srv.bezahleEinrichtung() Plattform-Webhook checkout.session.completed fuer die Bestellung der Einrichtung (signiert, /api/abo/webhook)
// srv.schliesseTicket() Einrichtungs-Ticket schliessen (workorders.status 'Closed'); GET /api/funktionen schaltet danach frei
// Haendler-Konto bei Stripe: srv.kontoFreigeben() (charges_enabled, payouts_enabled), Kasse des Kaeufers: srv.bezahleKasse(sessionId) +
// srv.sendeConnect(type, objekt) (signierter Connect-Webhook an /api/shop/stripe-webhook).
//
// Adressen: Der Server lauscht auf srv.url (http://127.0.0.1:PORT, fuer Webhooks und Direktaufrufe). Das Portal heisst fuer Server und Browser
// srv.portalUrl (https://project.webklar.test = config.publicBaseUrl, wie in Produktion https); das Skript leitet diese Adresse im Browser per
// page.route an srv.url um (route.fetch). Die Website des Haendlers heisst srv.kundenUrl (https://www.toepferei-brandt.example = liveDomain des
// Projekts); ihre Seiten liefert das Skript per page.route. So sind Stripe-Rueckspruenge, Bild-Adressen (Widget verlangt https) und
// Cross-Origin-Anfragen (data-api) so wie im Betrieb.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'
import { startAppwriteMock } from '../support/appwriteMock.js'
import { startFakeStripe } from '../support/fakeStripe.js'
import { mailSink } from '../support/mailSink.js'
import { schemaKeys } from '../support/schema.js'
import { useTestEnv } from '../support/testEnv.js'
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
export const PORTAL_URL = 'https://project.webklar.test'
export const KUNDEN_URL = 'https://www.toepferei-brandt.example'
export const KUNDE = 'c1'
export const PROJEKT = 'p1'
export const FEATURE = 'f_p1_shop'
export const CONNECT_SECRET = 'whsec_connect_ui_test'
export const KUNDEN_EMAIL = 'hanna@toepferei-brandt.example'
export const KUNDEN_NAME = 'Hanna Brandt'
export const MITARBEITER = { name: 'Kenso', email: 'kenso@webklar.com' }
/** Anfrage, wie sie das Formular im Portal schickt. */
export const ANFRAGE = {
warenart: 'kunst',
beschreibung: 'Handgemachte Keramik, Tassen und Vasen aus eigener Werkstatt in Winnweiler.',
artikelzahl: 'bis20',
versand: true,
abholung: true,
unternehmer: true,
keineAusgeschlossenen: true,
nachricht: 'Wir würden gern vor Weihnachten starten.',
}
export async function startShopUiServer() {
const { SHOP_DATETIMES } = await import('../../server/services/shop/schema.js')
const { TERMINE_DATETIMES } = await import('../../server/services/termine/schema.js')
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
termineOutbox: schemaKeys('termineOutbox'),
featureConsents: schemaKeys('featureConsents'),
subscriptions: schemaKeys('subscriptions'),
},
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../../server/config.js')
const { startTestApp, postWebhook } = await import('../support/testApp.js')
const { setMailTransportForTests } = await import('../../server/services/mailer.js')
const { deleteDocument, updateDocument } = await import('../../server/services/appwriteAdmin.js')
const { default: publicShopRoutes, resetPublicShopForTests, setzeUhrFuerTests: setzeUhrPublic } = await import('../../server/routes/publicShop.js')
const { default: shopRoutes, resetShopRoutesForTests } = await import('../../server/routes/shop.js')
const { default: shopWebhookRoutes } = await import('../../server/routes/shopWebhook.js')
const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js')
const { default: aboRoutes } = await import('../../server/routes/abo.js')
const { shopSeiteRouter, resetShopSeiteForTests } = await import('../../server/routes/shopSeite.js')
const { legalRouter } = await import('../../server/services/legalDocs.js')
const { entscheideAnfrage } = await import('../../server/services/shop/anfrage.js')
const { leereCache } = await import('../../server/services/shop/kontext.js')
const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js')
const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js')
const { getFeatureDoc } = await import('../../server/services/siteFeatures.js')
// Reihenfolge wie server/index.js: /embed (CORS, Cache), /rechtliches, Shop-Seiten, public/
const statisch = express.Router()
statisch.use('/embed/', (_req, res, next) => {
res.set('Access-Control-Allow-Origin', '*')
next()
})
statisch.use('/rechtliches', legalRouter())
statisch.use(shopSeiteRouter())
statisch.use(express.static(PUBLIC_DIR))
const app = await startTestApp({
'/api/public/shop/v1': publicShopRoutes,
'/api/shop/stripe-webhook': shopWebhookRoutes,
'/api/shop': shopRoutes,
'/api/funktionen': funktionenRoutes,
'/api/abo': aboRoutes,
'/': statisch,
})
const uploadDir = await mkdtemp(path.join(tmpdir(), 'shop-ui-'))
const vorher = {
mode: config.shop.mode,
pilotEmails: config.shop.pilotEmails,
connectWebhookSecret: config.shop.connectWebhookSecret,
formMinSekunden: config.shop.formMinSekunden,
minAntwortMs: config.shop.minAntwortMs,
uploadDir: config.shop.uploadDir,
publicBaseUrl: config.publicBaseUrl,
smtpFrom: config.smtp.from,
}
const col = (name) => config.collections[name]
const srv = {
url: app.url,
portalUrl: PORTAL_URL,
kundenUrl: KUNDEN_URL,
KUNDE, PROJEKT, FEATURE,
aw, st, config,
sink: null,
warteAufVersand,
/** Cookie des Portal-Kunden (c1) fuer page.context().addCookies([...]): { name, value, url }. */
async portalCookie() {
const { sessionCookie } = await import('../support/testApp.js')
const roh = sessionCookie({ customerId: KUNDE, appwriteUserId: `u_${KUNDE}`, name: KUNDEN_NAME, email: KUNDEN_EMAIL })
const i = roh.indexOf('=')
return { name: roh.slice(0, i), value: roh.slice(i + 1), url: PORTAL_URL }
},
// ---------------------------------------------------------------- Dokumente lesen
featureDoc() { return [...aw.docs('portalFeatures').values()].find((d) => d.featureKey === 'shop') ?? null },
shopDoc() { return [...aw.docs('shopConfig').values()][0] ?? null },
siteKey() { return srv.featureDoc()?.siteKey ?? srv.shopDoc()?.siteKey ?? null },
einstellungen() { return JSON.parse(srv.shopDoc().einstellungen) },
rechtstexte() { return JSON.parse(srv.shopDoc().rechtstexte) },
produkte() { return [...aw.docs('shopProducts').values()].map((d) => ({ ...d, produkt: JSON.parse(d.produkt) })) },
bestellungen() { return [...aw.docs('shopOrders').values()].map((d) => ({ ...d, daten: JSON.parse(d.bestellung) })) },
widerrufe() { return [...aw.docs('shopWiderrufe').values()] },
/** Mails der Senke; mit Teiltext des Betreffs filtern. Vorher warteAufVersand() (die Warteschlange sendet im Hintergrund). */
mails(betreff) { return srv.sink.sent.filter((m) => !betreff || String(m.subject).includes(betreff)) },
outbox() { return [...aw.docs('termineOutbox').values()] },
// ---------------------------------------------------------------- Mitarbeiter-Schritte (Dienste direkt)
async entscheide(entscheidung = 'annehmen', begruendung = '') {
return entscheideAnfrage({ customerId: KUNDE, projectId: PROJEKT, entscheidung, begruendung, person: MITARBEITER })
},
/** Bestellung der Einrichtung (Zustand ordered) bezahlen: Sitzung abschliessen, signierter Plattform-Webhook. Liefert { status } der Antwort. */
async bezahleEinrichtung() {
const doc = srv.featureDoc()
if (!doc?.stripeSessionId) throw new Error(`keine Bestellung der Einrichtung (Zustand ${doc?.state})`)
const sessionId = doc.stripeSessionId
const invoice = `in_${sessionId}`
st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` })
const sitzung = { ...st.checkoutSessions.get(sessionId), status: 'complete', payment_status: 'paid', invoice }
st.checkoutSessions.set(sessionId, sitzung)
const event = { id: `evt_${Math.random()}`, object: 'event', type: 'checkout.session.completed', data: { object: sitzung } }
return postWebhook(event)
},
/** Einrichtungs-Ticket der Funktion schliessen. */
async schliesseTicket() {
const doc = srv.featureDoc()
if (!doc?.ticketId) throw new Error(`kein Ticket (Zustand ${doc?.state})`)
await updateDocument(col('workorders'), doc.ticketId, { status: 'Closed' })
},
// ---------------------------------------------------------------- Stripe (Haendlerkonto)
/** Das Haendlerkonto des Shops nimmt Zahlungen an (wie nach dem Onboarding bei Stripe). */
kontoFreigeben(patch = {}) {
const id = srv.shopDoc()?.stripeAccountId
if (!id) throw new Error('kein Stripe-Konto angelegt')
return st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [], disabled_reason: null }, ...patch })
},
kontoId() { return srv.shopDoc()?.stripeAccountId ?? null },
/** Offene Checkout-Sitzungen des Haendlerkontos. */
kassenSitzungen() { return [...st.accountSessions(srv.kontoId()).values()] },
/** Sitzung des Kaeufers als bezahlt abschliessen (Stripe-Nachbau); liefert die Sitzung, wie sie der Webhook traegt. */
bezahleKasse(sessionId, { zahlung = 'pi_ui_1', kunde, versand, betrag } = {}) {
const acct = srv.kontoId()
const sitzung = st.accountSessions(acct).get(sessionId)
if (!sitzung) throw new Error(`keine Sitzung ${sessionId}`)
const order = srv.bestellungen().find((o) => o.stripeSessionId === sessionId)
const summe = betrag ?? order?.summeCent ?? null
st.setPaymentIntent(acct, zahlung, { amount: summe })
return st.completeSession(acct, sessionId, {
payment_status: 'paid',
amount_total: summe,
customer_details: kunde ?? {
name: 'Max Mustermann', email: 'max@example.com', phone: '+49 170 1234567',
address: { line1: 'Rechnungsweg 1', line2: null, postal_code: '12345', city: 'Musterstadt', country: 'DE' },
},
shipping: versand ?? { name: 'Max Mustermann', address: { line1: 'Lieferweg 7', line2: null, postal_code: '67655', city: 'Kaiserslautern', country: 'DE', state: null } },
payment_intent: zahlung,
payment_method_types: ['card'],
})
},
/** Signiertes Connect-Ereignis an POST /api/shop/stripe-webhook -> { status, body }. */
sendeConnect(type, objekt, { account } = {}) {
const event = { id: `evt_${Math.random().toString(36).slice(2)}`, object: 'event', type, livemode: false, account: account ?? srv.kontoId(), data: { object: objekt } }
return postWebhook(event, { path: '/api/shop/stripe-webhook', secret: CONNECT_SECRET })
},
leereCache() { leereCache() },
/**
* Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke; Kunde c1 mit Projekt p1 und Wartungs-Abo-freier
* Bestellung (der Webshop verlangt kein Abo). Schalter: Pilotmodus fuer alle Kunden ('*').
*/
async zuruecksetzen() {
await warteAufVersand()
const sammlungen = [
'portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'shopRechtstexteArchiv', 'termineOutbox',
'featureConsents', 'customers', 'websiteProjects', 'workorders', 'subscriptions',
]
for (const collection of sammlungen) {
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(col(collection) ?? collection, id)
}
st.checkoutSessions.clear()
st.invoices.clear()
st.calls.length = 0
leereCache()
resetOutboxForTests()
resetTermineLimitsForTests()
resetPublicShopForTests()
resetShopRoutesForTests()
resetShopSeiteForTests()
setzeUhrPublic(() => new Date())
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['*']
config.shop.connectWebhookSecret = CONNECT_SECRET
config.shop.formMinSekunden = 0
config.shop.minAntwortMs = 0
config.shop.uploadDir = uploadDir
config.smtp.from = 'noreply@webklar.com'
config.publicBaseUrl = PORTAL_URL
srv.sink = mailSink()
setMailTransportForTests(srv.sink)
aw.seed('websiteProjects', {
$id: PROJEKT, customerId: KUNDE, projectName: 'Töpferei Brandt', subdomain: 'toepferei', liveDomain: new URL(KUNDEN_URL).host,
})
aw.seed('customers', { $id: KUNDE, appwriteUserId: `u_${KUNDE}`, name: KUNDEN_NAME, email: KUNDEN_EMAIL, status: 'active', portalAccessEnabled: true })
},
/** Beendet Server und Nachbauten (zuerst den Browser schliessen) und stellt config wieder her. */
async stop() {
await warteAufVersand().catch(() => {})
setMailTransportForTests(null)
resetPublicShopForTests()
resetShopRoutesForTests()
Object.assign(config.shop, {
mode: vorher.mode, pilotEmails: vorher.pilotEmails, connectWebhookSecret: vorher.connectWebhookSecret, formMinSekunden: vorher.formMinSekunden,
minAntwortMs: vorher.minAntwortMs, uploadDir: vorher.uploadDir,
})
config.publicBaseUrl = vorher.publicBaseUrl
config.smtp.from = vorher.smtpFrom
let frist = null
const geschlossen = app.close()
app.server.closeAllConnections?.() // offene Verbindungen des Browsers (Keep-Alive) halten close() sonst auf
await Promise.race([geschlossen, new Promise((resolve) => { frist = setTimeout(resolve, 3000) })])
clearTimeout(frist)
await Promise.all([aw.close(), st.close()])
await rm(uploadDir, { recursive: true, force: true })
},
}
// getFeatureDoc ist nur fuer Skripte gedacht, die den Zustand pruefen wollen
srv.getFeatureDoc = () => getFeatureDoc(KUNDE, PROJEKT, 'shop')
await srv.zuruecksetzen()
return srv
}