Shop Review B I-6, I-7: Archiv der bestaetigten Rechtstexte, G09 ohne Link-Ersatz

I-6: Neue Collection shopRechtstexteArchiv (ID r + 35 Hex aus sha256(shopId + hash)); das
Bestaetigen legt die Fassung samt Versand-/Zahlungstext und Gewaehrleistungstext ab, G09 zeigt
die Fassung zum rechtstexteHash der Bestellung (Rueckfall mit Warnung auf die aktuell
bestaetigte), loescheShopDaten loescht das Archiv mit.

I-7: Grenzen AGB 20000, Belehrung 12000, Formular 3000 plus Pruefung der maskierten Laenge beim
Speichern; G09 ersetzt die AGB nie mehr durch einen Link, sondern scheitert (Marker bleibt
offen, wird wiederholt). Test mit Texten in Hoechstlaenge gegen die Grenzen der Warteschlange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:58:31 +00:00
1 parent 2f4f360a21
commit 08d837d690
17 files changed
+288 -65

No files matched your search

+1 -1
View File
@@ -373,7 +373,7 @@ Diese Punkte erledigt der Inhaber; kein Punkt ist Aufgabe der Kanzlei. Die Reihe
### 7.3 Technik
- [ ] `node scripts/shop-setup.mjs --check`, dann `node scripts/shop-setup.mjs` (vier Collections) und `node scripts/stripe-products.mjs --anlegen` (Produkt `shopSetup`), vor dem Code-Deploy.
- [ ] `node scripts/shop-setup.mjs --check`, dann `node scripts/shop-setup.mjs` (fünf Collections, darunter das Archiv der bestätigten Rechtstexte) und `node scripts/stripe-products.mjs --anlegen` (Produkt `shopSetup`), vor dem Code-Deploy.
- [ ] Connect-Webhook in Stripe anlegen (Ereignisse: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `account.updated`, `charge.refunded`), Geheimnis als `STRIPE_CONNECT_WEBHOOK_SECRET` in `/home/webklar/apps/.env`, Zeilen `SHOP_ENABLED` und `SHOP_PILOT_EMAILS` in der Compose-Datei.
- [ ] Code nach `main` (nach dem Terminplaner), WOMS-Zweig nach `test`, Deploy zu einer ruhigen Zeit.
- [ ] PDF der Datenschutzerklärung mit `scripts/build-legal-pdfs.sh datenschutz` (nicht `all`), PDFs des Webshops mit `scripts/build-legal-pdfs.sh shop`; Prüfsummen mit den Tests abgleichen.
+3 -3
View File
@@ -18,9 +18,9 @@ export const NICHT_BESTAETIGT = 'Änderungen noch nicht bestätigt'
export const GEWAEHRLEISTUNG_NOTIZ = 'Dieser Hinweis wird in deinem Shop automatisch vor dem Bestell-Knopf angezeigt. Du musst nichts dafür tun.'
const TEXTE = [
{ key: 'agb', label: 'AGB', zeilen: 14, max: 30000, hinweis: 'Das Muster ist für kleine Shops gedacht. Prüfe es und passe es an dein Angebot an.' },
{ key: 'widerrufsbelehrung', label: 'Widerrufsbelehrung', zeilen: 14, max: 20000, hinweis: 'Das Muster nutzt deine Angaben aus den Einstellungen (Anbieter, Rücksendekosten). Prüfe sie dort, bevor du das Muster einsetzt.' },
{ key: 'widerrufsformular', label: 'Muster-Widerrufsformular', zeilen: 8, max: 5000, hinweis: null },
{ key: 'agb', label: 'AGB', zeilen: 14, max: 20000, hinweis: 'Das Muster ist für kleine Shops gedacht. Prüfe es und passe es an dein Angebot an. Höchstens 20.000 Zeichen: Der Text steht vollständig in der Bestätigungs-E-Mail an deine Kunden.' },
{ key: 'widerrufsbelehrung', label: 'Widerrufsbelehrung', zeilen: 14, max: 12000, hinweis: 'Das Muster nutzt deine Angaben aus den Einstellungen (Anbieter, Rücksendekosten). Prüfe sie dort, bevor du das Muster einsetzt. Höchstens 12.000 Zeichen. Die Widerrufsbelehrung muss die Adresse deiner Widerrufsfunktion enthalten; das Muster tut das.' },
{ key: 'widerrufsformular', label: 'Muster-Widerrufsformular', zeilen: 8, max: 3000, hinweis: 'Höchstens 3.000 Zeichen.' },
]
const ADRESSEN = [
{ key: 'impressumUrl', label: 'Adresse deines Impressums', platzhalter: 'https://www.deine-website.de/impressum' },
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Webshop: legt die vier Collections shopConfig, shopProducts, shopOrders und shopWiderrufe mit
* Webshop: legt die fuenf Collections shopConfig, shopProducts, shopOrders, shopWiderrufe und shopRechtstexteArchiv mit
* Attributen und Indizes in Appwrite an (nur Server-Key, keine Client-Rechte). Das Schema steht in
* server/services/shop/schema.js. Idempotent: Vorhandenes wird uebersprungen, fehlende Attribute und Indizes
* auch in schon vorhandenen Collections nachgezogen. Vor dem Einschalten (SHOP_ENABLED) ausfuehren.
+2 -1
View File
@@ -537,11 +537,12 @@ export const config = {
termineBookings: process.env.APPWRITE_COLLECTION_TERMINE_BOOKINGS || 'termineBookings',
termineClaims: process.env.APPWRITE_COLLECTION_TERMINE_CLAIMS || 'termineClaims',
termineOutbox: process.env.APPWRITE_COLLECTION_TERMINE_OUTBOX || 'termineOutbox',
// Webshop (Einstellungen, Artikel, Bestellungen, Widerrufe; scripts/shop-setup.mjs)
// Webshop (Einstellungen, Artikel, Bestellungen, Widerrufe, Archiv der bestaetigten Rechtstexte; scripts/shop-setup.mjs)
shopConfig: process.env.APPWRITE_COLLECTION_SHOP_CONFIG || 'shopConfig',
shopProducts: process.env.APPWRITE_COLLECTION_SHOP_PRODUCTS || 'shopProducts',
shopOrders: process.env.APPWRITE_COLLECTION_SHOP_ORDERS || 'shopOrders',
shopWiderrufe: process.env.APPWRITE_COLLECTION_SHOP_WIDERRUFE || 'shopWiderrufe',
shopRechtstexteArchiv: process.env.APPWRITE_COLLECTION_SHOP_RECHTSTEXTE_ARCHIV || 'shopRechtstexteArchiv',
},
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
+10
View File
@@ -17,6 +17,7 @@ import { bereitschaft, istGeoeffnet, ladeShop, leereCache } from '../services/sh
import {
bildIdsInGebrauch, holeProdukt, leseBestandGeladen, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt,
} from '../services/shop/produkte.js'
import { archiviereRechtstexte } from '../services/shop/archiv.js'
import {
BESTAETIGUNG_TEXT, GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, musterAgb, musterWiderrufsbelehrung, musterWiderrufsformular,
normalisiereRechtstexte, pruefeBestaetigbar, rechtstexteHash, versandUndZahlung, vollstaendig,
@@ -434,6 +435,15 @@ router.post('/rechtstexte/bestaetigen', requireSession, nurKunde, limitWrites, r
}
const at = new Date().toISOString()
const bestaetigt = { texte: { ...entwurf }, hash, at, von }
// Fassung ins Archiv (G09 zeigt dem Kaeufer die Fassung seiner Bestellung); scheitert das, wird nichts bestaetigt
await archiviereRechtstexte({
shopId: shop.id,
customerId: shop.customerId,
hash,
texte: { ...entwurf, versandUndZahlung: versandUndZahlung(aktuell.einstellungen), gewaehrleistung: gewaehrleistungsHinweis() },
at,
von,
})
const json = JSON.stringify({ entwurf, bestaetigt })
if (json.length > PRODUKT_DOC_MAX_JSON) throw stop(400, 'Die Rechtstexte sind zu umfangreich. Bitte kürze die Texte.')
await updateDocument(config.collections.shopConfig, shop.id, {
+2 -2
View File
@@ -29,7 +29,7 @@ import { filesOf, STATUS } from './takeoverCredits.js'
* - Bestellangaben am Funktionsdokument: Fruehere Bestellungen speicherten IP, Browser, Zustimmungstext und -zeit auch am Dokument
* (portalFeatures.consentIp, consentUserAgent, consentText, consentAt). Fuer faellige Kunden werden sie an ALLEN ihren
* Funktionsdokumenten geleert, auch an abgeschalteten (plan.news[].featureFieldIds); die Dokumente selbst bleiben (siteKey).
* - Webshop: Fuer faellige Kunden loescht derselbe Lauf auch die Daten ihrer Shops (Bestellungen, Artikel, Widerrufe, Artikelbilder, shopConfig;
* - Webshop: Fuer faellige Kunden loescht derselbe Lauf auch die Daten ihrer Shops (Bestellungen, Artikel, Widerrufe, Archiv der bestaetigten Rechtstexte, Artikelbilder, shopConfig;
* plan.news[].shops, nur wenn es welche gibt). Je Kunde hoechstens SHOP_LOESCH_DECKEL Dokumente je Lauf; bleibt etwas uebrig, wird der
* Kunde gehalten (held) und der naechste Lauf macht weiter. Die eigenen Fristen des Shops (Anonymisierung, 90 Tage nach dem
* Abschalten) laufen unabhaengig im Tagesjob des Shops (shop/jobs.js).
@@ -268,7 +268,7 @@ export async function applyDeletions(plan) {
for (const id of entry.consentIds || []) {
if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1
}
// Webshop-Daten: Bestellungen, Artikel, Widerrufe, Bilder, zuletzt shopConfig (Deckel je Kunde; Rest = der Kunde wird gehalten)
// Webshop-Daten: Bestellungen, Artikel, Widerrufe, Rechtstexte-Archiv, Bilder, zuletzt shopConfig (Deckel je Kunde; Rest = der Kunde wird gehalten)
let shopRest = SHOP_LOESCH_DECKEL
for (const shop of entry.shops || []) {
await attempt('shopConfig', shop.id, async () => {
+60
View File
@@ -0,0 +1,60 @@
import { createHash } from 'node:crypto'
import { config } from '../../config.js'
import { createDocument, getDocument } from '../appwriteAdmin.js'
/**
* Archiv der bestaetigten Rechtstexte eines Shops (Review B, I-6). Beim Bestaetigen wird die Fassung samt Hash abgelegt; G09 zeigt dem
* Kaeufer die Fassung zum Hash seiner Bestellung (bestellung.rechtstexteHash), nicht die beim Mailversand aktuelle. So passen Mail und
* Bestellung zusammen, und der Haendler kann spaeter belegen, welche Belehrung ein Kaeufer bekam (§ 356 Abs. 3 BGB).
* Eine Fassung je Dokument, ID = 'r' + die ersten 35 Hex-Zeichen von sha256(shopId + hash); dieselbe Fassung ein zweites Mal
* (409) ist kein Fehler. Inhalt (texte, JSON): agb, widerrufsbelehrung, widerrufsformular, impressumUrl, datenschutzUrl,
* versandUndZahlung, gewaehrleistung.
*/
const col = () => config.collections.shopRechtstexteArchiv
/** Dokument-ID der Fassung (hoechstens 36 Zeichen, nur Hex nach dem 'r'). */
export const archivId = (shopId, hash) => `r${createHash('sha256').update(`${String(shopId)}${String(hash)}`).digest('hex').slice(0, 35)}`
/**
* Legt die Fassung an, falls es sie noch nicht gibt.
* @param {{ shopId: string, customerId: string, hash: string, texte: object, at: string, von: string }} p
* @returns {Promise<{ angelegt: boolean }>}
*/
export async function archiviereRechtstexte({ shopId, customerId, hash, texte, at, von }) {
if (typeof shopId !== 'string' || shopId === '' || typeof hash !== 'string' || !/^[0-9a-f]{64}$/.test(hash)) throw new TypeError('archiviereRechtstexte: shopId oder hash fehlt')
try {
await createDocument(
col(),
{ shopId, customerId: String(customerId ?? ''), hash, texte: JSON.stringify(texte), bestaetigtAt: at, bestaetigtVon: String(von ?? '').slice(0, 255) },
archivId(shopId, hash),
)
return { angelegt: true }
} catch (err) {
if (err?.status === 409) return { angelegt: false }
throw err
}
}
/**
* Die archivierte Fassung zum Hash oder null, wenn es sie nicht gibt (zum Beispiel bei Bestellungen vor der Einfuehrung des Archivs).
* Andere Fehler (Datenbank) werfen, damit G09 nicht mit falschen Texten rausgeht, sondern wiederholt wird.
* @returns {Promise<object|null>}
*/
export async function holeArchivierteRechtstexte(shopId, hash) {
if (typeof shopId !== 'string' || shopId === '' || typeof hash !== 'string' || hash === '') return null
let doc
try {
doc = await getDocument(col(), archivId(shopId, hash))
} catch (err) {
if (err?.status === 404) return null
throw err
}
if (doc?.shopId !== shopId || doc?.hash !== hash) return null
try {
const texte = JSON.parse(doc.texte)
return texte !== null && typeof texte === 'object' && !Array.isArray(texte) ? texte : null
} catch {
return null
}
}
+5 -4
View File
@@ -3,7 +3,7 @@ import { deleteDocument, listDocuments, Query } from '../appwriteAdmin.js'
import { loescheAlleBilder } from './bilder.js'
/**
* Alle Daten EINES Shops loeschen (Plan 2026-10-05-webshop.md, J3): Bestellungen, Artikel, Widerrufe, Artikelbilder und zuletzt das
* Alle Daten EINES Shops loeschen (Plan 2026-10-05-webshop.md, J3): Bestellungen, Artikel, Widerrufe, das Archiv der bestaetigten Rechtstexte, Artikelbilder und zuletzt das
* shopConfig-Dokument. Gemeinsamer Baustein des Tagesjobs (Shop seit 90 Tagen abgeschaltet, jobs.js) und der Loeschfristen
* (retention.js, Kunde faellig). Bewusst ohne Import von bestellungen.js/Stripe: retention.js darf nichts Schweres nachladen.
* Das Stripe-Konto gehoert dem Haendler und wird nie angefasst.
@@ -17,17 +17,18 @@ const GRUPPEN = [
['shopOrders', 'bestellungen'],
['shopProducts', 'artikel'],
['shopWiderrufe', 'widerrufe'],
['shopRechtstexteArchiv', 'archiv'],
]
/**
* @param {{ shopId: string, siteKey?: string, deckel?: number, nochGueltig?: () => Promise<boolean> }} opt
* deckel: hoechstens so viele Dokumente (Bestellungen + Artikel + Widerrufe); nochGueltig wird vor jeder Seite gefragt (false = abbrechen)
* @returns {Promise<{ bestellungen: number, artikel: number, widerrufe: number, geloescht: number, konfig: boolean, vollstaendig: boolean, abgebrochen: boolean }>}
* deckel: hoechstens so viele Dokumente (Bestellungen + Artikel + Widerrufe + Archiv); nochGueltig wird vor jeder Seite gefragt (false = abbrechen)
* @returns {Promise<{ bestellungen: number, artikel: number, widerrufe: number, archiv: number, geloescht: number, konfig: boolean, vollstaendig: boolean, abgebrochen: boolean }>}
* vollstaendig = alle Dokumente, Bilder und das shopConfig-Dokument sind weg
*/
export async function loescheShopDaten({ shopId, siteKey, deckel = Number.POSITIVE_INFINITY, nochGueltig = async () => true }) {
if (typeof shopId !== 'string' || shopId === '') throw new TypeError('loescheShopDaten: shopId fehlt')
const erg = { bestellungen: 0, artikel: 0, widerrufe: 0, geloescht: 0, konfig: false, vollstaendig: false, abgebrochen: false }
const erg = { bestellungen: 0, artikel: 0, widerrufe: 0, archiv: 0, geloescht: 0, konfig: false, vollstaendig: false, abgebrochen: false }
for (const [name, feld] of GRUPPEN) {
const sammlung = config.collections[name]
for (;;) {
+27 -18
View File
@@ -7,6 +7,7 @@ import { pruefeNow, zeitMs } from '../termine/util.js'
import { datumKurz, uhrzeit } from '../termine/zeit.js'
import { euro } from './geld.js'
import { oeffentlicheRechtstexte } from './kontext.js'
import { holeArchivierteRechtstexte } from './archiv.js'
import { gewaehrleistungsHinweis } from './rechtstexte.js'
/**
@@ -162,16 +163,17 @@ const datumVon = (now, ...zeitpunkte) => {
* @param {{ doc?: object, shop: object }} ctx
* @param {object|null} order
* @param {Date} [now] Ersatzdatum, wenn die Bestellung keinen Zeitpunkt hat
* @param {{ archiv?: object|null }} [optionen] archiv: archivierte Rechtstexte der Bestellung (archiv.js); ohne sie die aktuell bestaetigten
* @returns {Record<string, string>}
*/
export function kaeuferWerte(ctx, order, now = new Date()) {
export function kaeuferWerte(ctx, order, now = new Date(), { archiv = null } = {}) {
if (!istObjekt(ctx?.shop)) throw new TypeError('kaeuferWerte: ctx.shop fehlt')
const snap = schnappschuss(order)
const e = einstellungenVon(ctx)
const auszug = istObjekt(snap.einstellungenAuszug) ? snap.einstellungenAuszug : {}
const anbieter = anbieterVon(ctx, snap)
const abholung = { ...(e.abholung ?? {}), ...(istObjekt(auszug.abholung) ? auszug.abholung : {}) }
const rechtstexte = oeffentlicheRechtstexte(ctx) ?? {}
const rechtstexte = (istObjekt(archiv) ? archiv : oeffentlicheRechtstexte(ctx)) ?? {}
const summen = istObjekt(snap.summen) ? snap.summen : snap
const texte = istObjekt(summen.texte) ? summen.texte : {}
const cent = (wert) => (Number.isInteger(wert) ? euro(wert) : LEER)
@@ -204,7 +206,7 @@ export function kaeuferWerte(ctx, order, now = new Date()) {
agb: oderLeer(rechtstexte.agb),
widerrufsbelehrung: oderLeer(rechtstexte.widerrufsbelehrung),
widerrufsformular: oderLeer(rechtstexte.widerrufsformular),
gewaehrleistung: gewaehrleistungsHinweis(),
gewaehrleistung: hatText(rechtstexte.gewaehrleistung) ? rechtstexte.gewaehrleistung.trim() : gewaehrleistungsHinweis(),
// fuer G11 (Standard, wenn der Haendler keinen Hinweis hinterlegt hat)
hinweis: text(abholung.hinweis) || ABHOLUNG_HINWEIS,
}
@@ -219,8 +221,8 @@ export function kaeuferWerte(ctx, order, now = new Date()) {
* G12 name, bestellnummer (Eingaben des Kaeufers; ohne Angabe die der Bestellung), eingang (Date | ISO, Pflicht), nachricht
* G13 betragCent (Pflicht, ganze Cent), grund
*/
function kaeuferMail(art, ctx, order, extra, now) {
const alle = kaeuferWerte(ctx, order, now)
function kaeuferMail(art, ctx, order, extra, now, archiv = null) {
const alle = kaeuferWerte(ctx, order, now, { archiv })
const blocks = {}
switch (art) {
case 'G09':
@@ -257,38 +259,45 @@ function kaeuferMail(art, ctx, order, extra, now) {
return { values, blocks: Object.fromEntries(Object.entries(blocks).filter(([name]) => vorlage.blocks.includes(name))) }
}
/** Archivierte Rechtstexte zum Hash der Bestellung (G09); null (mit Warnung) fuer Bestellungen ohne Archiveintrag. Datenbankfehler werfen. */
async function archivFuer(ctx, order) {
const hash = text(schnappschuss(order).rechtstexteHash)
if (hash === '') return null
const shopId = ctx?.shop?.id || ctx?.doc?.$id
const archiv = await holeArchivierteRechtstexte(shopId, hash)
if (!archiv) console.warn(`[shop-mails] G09: Rechtstexte der Bestellung ${String(order?.$id).slice(0, 36)} fehlen im Archiv, die aktuell bestätigte Fassung wird verwendet`)
return archiv
}
const zuLang = (mail) => mail.html.length > MAX_HTML || mail.text.length > MAX_TEXT
// ---------------------------------------------------------------- Einreihen
/**
* Reiht eine Kaeufermail (G09-G14) in die Warteschlange ein. Empfaenger ist extra.an, sonst order.kaeuferEmail. bookingId ist
* order.$id, sonst extra.bookingId (z. B. die ID des Widerrufs bei G12), sonst "-". Ist G09 zu lang fuer die Warteschlange
* (AGB, Widerrufsbelehrung und Formular koennen zusammen sehr lang sein), ersetzt der Server den AGB-Text durch einen Satz mit
* Link auf die AGB im Shop; Widerrufsbelehrung, Formular und Gewaehrleistungshinweis bleiben immer vollstaendig.
* order.$id, sonst extra.bookingId (z. B. die ID des Widerrufs bei G12), sonst "-". G09 enthaelt die Rechtstexte in der Fassung der
* Bestellung (archiv.js, Hash bestellung.rechtstexteHash); fehlt sie im Archiv, gilt mit Warnung die aktuell bestaetigte. Ist die
* Mail zu lang fuer die Warteschlange, scheitert sie (ok: false), statt Texte durch einen Link zu ersetzen.
* @param {'G09'|'G10'|'G11'|'G12'|'G13'|'G14'} art
* @param {{ doc: object, shop: object }} ctx Shop-Kontext (kontext.js)
* @param {object|null} order shopOrders-Dokument (bei G12 ohne passende Bestellung null)
* @param {object} [extra] siehe kaeuferMail
* @param {Date} [now]
* @returns {Promise<{ ok: true, eintrag: object, gekuerzt: boolean } | { ok: false, fehler: string }>}
* @returns {Promise<{ ok: true, eintrag: object } | { ok: false, fehler: string }>}
*/
export async function anKaeufer(art, ctx, order, extra = {}, now = new Date()) {
try {
if (!GAST_ARTEN.includes(art)) throw new TypeError(`${String(art).slice(0, 20)} ist keine Käufermail`)
pruefeNow(now, 'anKaeufer')
extra = extra ?? {}
const { values, blocks } = kaeuferMail(art, ctx, order, extra, now)
const archiv = art === 'G09' ? await archivFuer(ctx, order) : null
const { values, blocks } = kaeuferMail(art, ctx, order, extra, now, archiv)
const an = hatText(extra.an) ? extra.an.trim() : text(order?.kaeuferEmail)
if (an === '') throw new TypeError('Empfänger fehlt')
let gekuerzt = false
let mail = renderMail(art, values, blocks)
if (art === 'G09' && zuLang(mail)) {
values.agb = `Die vollständigen AGB finden Sie unter ${shopSeite(ctx)}?ansicht=agb`
mail = renderMail(art, values, blocks)
gekuerzt = true
}
// Nie ein Link statt der Texte: die Bestaetigung muss den Vertragsinhalt selbst enthalten (§ 312f Abs. 2 BGB). Passt die Mail nicht
// in die Warteschlange, scheitert sie hier; der Aufrufer behaelt den Marker offen, zaehlt den Versuch und wiederholt.
const mail = renderMail(art, values, blocks)
if (zuLang(mail)) throw new TypeError('Die Mail ist zu lang für die Warteschlange')
const e = einstellungenVon(ctx)
@@ -306,7 +315,7 @@ export async function anKaeufer(art, ctx, order, extra = {}, now = new Date()) {
},
now,
)
return { ok: true, eintrag, gekuerzt }
return { ok: true, eintrag }
} catch (err) {
console.warn(`[shop-mails] ${String(art).slice(0, 20)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
return { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' }
+25 -3
View File
@@ -14,9 +14,13 @@ import { STANDARD_EINSTELLUNGEN } from './einstellungen.js'
export const 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.'
export const MAX_AGB = 30000
export const MAX_WIDERRUFSBELEHRUNG = 20000
export const MAX_WIDERRUFSFORMULAR = 5000
// Grenzen so gewaehlt, dass die Bestaetigungs-E-Mail G09 mit allen drei Texten in die Warteschlange passt (html 100000, text 50000;
// die Vorlage braucht rund 12000 Zeichen) und der Volltext nie durch einen Link ersetzt werden muss (§ 312f Abs. 2 BGB).
export const MAX_AGB = 20000
export const MAX_WIDERRUFSBELEHRUNG = 12000
export const MAX_WIDERRUFSFORMULAR = 3000
/** So viele Zeichen duerfen die drei Texte zusammen im HTML der Mail belegen (maskierte Zeichen und Zeilenumbrueche zaehlen mehrfach). */
export const MAX_MAIL_HTML_TEXTE = 60000
const MAX_URL = 500
/** Erste Zeile des vorbefuellten Muster-AGB: der Haendler muss sie nach der Pruefung loeschen. */
export const MUSTER_KOPFZEILE = 'Muster – bitte prüfen und anpassen'
@@ -230,6 +234,20 @@ export function gewaehrleistungsHinweis() {
].join('\n\n')
}
/** Laenge eines Textes im HTML der Mail (wie mailTemplates.renderMail: & < > \" ' maskiert, jeder Umbruch wird <br>). */
export function maskierteLaenge(wert) {
let n = 0
for (const z of String(wert)) {
if (z === '&') n += 5
else if (z === '<' || z === '>') n += 4
else if (z === '"') n += 6
else if (z === "'") n += 5
else if (z === '\n') n += 4
else if (z !== '\r') n += 1
}
return n
}
// --- Normalisierung --------------------------------------------------------------------------------------------------------
// Steuerzeichen (ausser Zeilenumbruch) und unsichtbare Formatzeichen raus
@@ -300,6 +318,10 @@ export function normalisiereRechtstexte(eingabe) {
out[key] = ''
} else out[key] = r.wert
}
// Passt die Bestaetigungs-E-Mail mit allen Texten noch in die Warteschlange? (& < > " ' werden mehrfach lang, jeder Umbruch wird <br>)
if (Object.keys(fehler).length === 0 && ['agb', 'widerrufsbelehrung', 'widerrufsformular'].reduce((summe, k) => summe + maskierteLaenge(out[k]), 0) > MAX_MAIL_HTML_TEXTE) {
fehler.rechtstexte = 'AGB, Widerrufsbelehrung und Widerrufsformular sind zusammen zu lang für die Bestätigungs-E-Mail an deine Kunden (viele Sonderzeichen und Zeilenumbrüche zählen mehrfach). Bitte kürze die Texte.'
}
if (Object.keys(fehler).length) return { fehler }
return {
rechtstexte: {
+9 -1
View File
@@ -1,5 +1,5 @@
/**
* Webshop: Appwrite-Schema der vier Collections (Spezifikation 2026-10-05-webshop-design.md).
* Webshop: Appwrite-Schema der fuenf Collections (Spezifikation 2026-10-05-webshop-design.md).
* Reine Daten, absichtlich ohne Import von server/config.js: test/support/schema.js liest sie vor useTestEnv(),
* ein frueh geladenes config.js wuerde den Endpunkt des Appwrite-Nachbaus verfehlen. Die Collection-IDs stehen in
* config.collections.<Name>; scripts/shop-setup.mjs legt alles an. Kein Feld heisst data (appwriteAdmin.formatRequestBody
@@ -97,6 +97,14 @@ export const SHOP_COLLECTIONS = {
],
indexes: [index('idx_shop', 'shopId', 'eingangAt')],
},
// Archiv der bestaetigten Rechtstexte (eine Fassung je Dokument, ID 'r' + 35 Hex-Zeichen aus sha256(shopId + hash)): G09 zeigt dem
// Kaeufer die Fassung zum Zeitpunkt seiner Bestellung, und der Haendler kann belegen, welche Belehrung galt. Keine personenbezogenen
// Daten ausser der Person, die bestaetigt hat; wird mit dem Shop geloescht (loeschen.js).
shopRechtstexteArchiv: {
attributes: [str('shopId', 36), str('customerId', 36), str('hash', 64), str('texte', 100000), dt('bestaetigtAt'), str('bestaetigtVon', 255)],
indexes: [index('idx_shop_hash', 'shopId', 'hash')],
},
}
/** Datumsfelder je Collection, damit der Appwrite-Nachbau der Tests sie wie Appwrite formatieren kann. */
+5 -3
View File
@@ -22,6 +22,7 @@ const aw = await startAppwriteMock({
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
},
})
const st = await startFakeStripe()
@@ -599,6 +600,7 @@ function seedFaelligMitShop(kunde, siteKey = SHOP_KEY) {
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')
@@ -640,8 +642,8 @@ test('Webshop: fällige Kunden verlieren auch Bestellungen, Artikel, Widerrufe,
assert.deepEqual(result.failed, [])
assert.deepEqual(result.held, [])
assert.equal(result.deleted.shops, 1)
assert.equal(result.deleted.shopDocs, 4)
for (const [col, ids] of [['shopOrders', ['sh1_ord1', 'sh1_ord2']], ['shopProducts', ['sh1_prod']], ['shopWiderrufe', ['sh1_wid']], ['shopConfig', ['f_sh1_shop']]]) {
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')
@@ -651,7 +653,7 @@ test('Webshop: fällige Kunden verlieren auch Bestellungen, Artikel, Widerrufe,
// 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 \(4 Dokument\(e\)/)
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 () => {
+20 -1
View File
@@ -16,6 +16,7 @@ const aw = await startAppwriteMock({
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES },
@@ -29,6 +30,7 @@ const { deleteDocument, updateDocument } = await import('../server/services/appw
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')
@@ -87,7 +89,7 @@ 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 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' })
@@ -185,6 +187,23 @@ test('schliesseAb: Normalfall - bezahlt, Käuferdaten, Bestand abgezogen, G09 an
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()
+66 -15
View File
@@ -5,12 +5,13 @@ 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') },
datetimes: TERMINE_DATETIMES,
schema: { termineOutbox: schemaKeys('termineOutbox'), shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv') },
datetimes: { ...TERMINE_DATETIMES, ...SHOP_DATETIMES },
defaultLimit: 25,
})
after(() => aw.close())
@@ -23,6 +24,8 @@ const { resetOutboxForTests, warteAufVersand } = await import('../server/service
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))
@@ -89,6 +92,7 @@ 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'
@@ -166,7 +170,6 @@ test('kaeuferWerte M-5: Telefonnummer des Anbieters steht in der Anbieterzeile,
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)
assert.equal(erg.gekuerzt, false)
await warteAufVersand()
const [doc] = eintraege()
@@ -222,30 +225,78 @@ test('G09 Abholung: Abholort statt Lieferadresse', async () => {
assert.deepEqual(warnLog(), [])
})
test('G09: zu lang für die Warteschlange ⇒ der AGB-Text wird durch einen Link auf die AGB ersetzt, Belehrung und Formular bleiben', async () => {
const lang = 'Dies ist ein langer Satz der Geschäftsbedingungen.\n'.repeat(560) // etwa 28 000 Zeichen
const t = texte({ agb: lang, widerrufsbelehrung: 'Belehrung Satz.\n'.repeat(1000), widerrufsformular: 'Formular Zeile\n'.repeat(300) })
const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung(), {}, JETZT)
/** 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)
assert.equal(erg.gekuerzt, true)
const doc = eintraege()[0]
assert.ok(doc.html.length <= 100000 && doc.text.length <= 50000, `${doc.html.length}/${doc.text.length}`)
assert.match(doc.text, new RegExp(`Die vollständigen AGB finden Sie unter https://project\\.webklar\\.com/shop/${SITE_KEY}\\?ansicht=agb`))
assert.doesNotMatch(doc.text, /langer Satz der Geschäftsbedingungen/)
assert.match(doc.text, /Belehrung Satz\./)
assert.match(doc.text, /Formular Zeile/)
assert.match(doc.text, /GESETZLICHE GEWÄHRLEISTUNG/)
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: auch gekürzt zu lang ⇒ { ok: false } ohne Wurf und ohne Eintrag', async () => {
const t = texte({ widerrufsbelehrung: 'x'.repeat(60000) })
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)
+27 -7
View File
@@ -14,6 +14,10 @@ import {
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'
@@ -244,13 +248,29 @@ test('normalisiereRechtstexte: gueltig, Entwurf mit leeren Texten, Bereinigung',
assert.equal(vollstaendig(leer.rechtstexte), false)
})
test('normalisiereRechtstexte: Laengen', () => {
assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(30000) }).rechtstexte)
assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(30001) }).fehler.agb)
assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(20000) }).rechtstexte)
assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(20001) }).fehler.widerrufsbelehrung)
assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(5000) }).rechtstexte)
assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(5001) }).fehler.widerrufsformular)
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', () => {
+14 -2
View File
@@ -18,6 +18,7 @@ const aw = await startAppwriteMock({
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
customerViewLog: schemaKeys('customerViewLog'),
},
datetimes: SHOP_DATETIMES,
@@ -32,6 +33,7 @@ const { kundenansichtGuard } = await import('../server/middleware/kundenansichtG
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')
@@ -117,7 +119,7 @@ beforeEach(async () => {
config.shop.mode = 'pilot'
config.shop.pilotEmails = [customers.c1.email]
resetShopRoutesForTests()
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'customerViewLog']) {
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 })
@@ -481,7 +483,7 @@ test('Rechtstexte PUT: speichert den Entwurf, normalisiert, Fehler 400; Hash aen
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(30001) } })
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')
})
@@ -516,6 +518,16 @@ test('Rechtstexte bestaetigen: falscher oder alter Hash 409, unvollstaendig 400,
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' } })
+11 -3
View File
@@ -15,7 +15,7 @@ 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']
const NAMES = ['shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'shopRechtstexteArchiv']
const keysOf = (name) => SHOP_COLLECTIONS[name].attributes.map((a) => a.key)
/**
@@ -127,6 +127,12 @@ test('Schema: Typen und Groessen', () => {
[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)', () => {
@@ -160,6 +166,7 @@ test('Schema: Indizes wie in der Spezifikation und nur auf vorhandenen Attribute
['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')
@@ -174,6 +181,7 @@ test('Schema: SHOP_DATETIMES nennt je Collection genau die datetime-Attribute',
shopProducts: [],
shopOrders: ['erstelltAt', 'ablaufAt', 'bezahltAt', 'anonymisiertAt'],
shopWiderrufe: ['eingangAt', 'bestaetigungAt', 'bearbeitetAt'],
shopRechtstexteArchiv: ['bestaetigtAt'],
})
})
@@ -215,7 +223,7 @@ test('Konfiguration: Collection-IDs der Shop-Collections', () => {
assert.deepEqual(NAMES.map((name) => config.collections[name]), NAMES)
})
test('main legt auf leerer Datenbank 4 Collections, alle Attribute und Indizes an', async () => {
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 })))
@@ -248,7 +256,7 @@ test('main: legt nur fehlende Attribute und Indizes einer vorhandenen Collection
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, 3)
assert.equal(client.calls.collections.length, 4)
})
test('main --check legt nichts an und meldet Fehlendes', async () => {