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 ### 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. - [ ] 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. - [ ] 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. - [ ] 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.' export const GEWAEHRLEISTUNG_NOTIZ = 'Dieser Hinweis wird in deinem Shop automatisch vor dem Bestell-Knopf angezeigt. Du musst nichts dafür tun.'
const TEXTE = [ 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: '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: 20000, hinweis: 'Das Muster nutzt deine Angaben aus den Einstellungen (Anbieter, Rücksendekosten). Prüfe sie dort, bevor du das Muster einsetzt.' }, { 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: 5000, hinweis: null }, { key: 'widerrufsformular', label: 'Muster-Widerrufsformular', zeilen: 8, max: 3000, hinweis: 'Höchstens 3.000 Zeichen.' },
] ]
const ADRESSEN = [ const ADRESSEN = [
{ key: 'impressumUrl', label: 'Adresse deines Impressums', platzhalter: 'https://www.deine-website.de/impressum' }, { 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 #!/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 * 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 * 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. * 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', termineBookings: process.env.APPWRITE_COLLECTION_TERMINE_BOOKINGS || 'termineBookings',
termineClaims: process.env.APPWRITE_COLLECTION_TERMINE_CLAIMS || 'termineClaims', termineClaims: process.env.APPWRITE_COLLECTION_TERMINE_CLAIMS || 'termineClaims',
termineOutbox: process.env.APPWRITE_COLLECTION_TERMINE_OUTBOX || 'termineOutbox', 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', shopConfig: process.env.APPWRITE_COLLECTION_SHOP_CONFIG || 'shopConfig',
shopProducts: process.env.APPWRITE_COLLECTION_SHOP_PRODUCTS || 'shopProducts', shopProducts: process.env.APPWRITE_COLLECTION_SHOP_PRODUCTS || 'shopProducts',
shopOrders: process.env.APPWRITE_COLLECTION_SHOP_ORDERS || 'shopOrders', shopOrders: process.env.APPWRITE_COLLECTION_SHOP_ORDERS || 'shopOrders',
shopWiderrufe: process.env.APPWRITE_COLLECTION_SHOP_WIDERRUFE || 'shopWiderrufe', 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(/\/$/, ''), 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 { import {
bildIdsInGebrauch, holeProdukt, leseBestandGeladen, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt, bildIdsInGebrauch, holeProdukt, leseBestandGeladen, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt,
} from '../services/shop/produkte.js' } from '../services/shop/produkte.js'
import { archiviereRechtstexte } from '../services/shop/archiv.js'
import { import {
BESTAETIGUNG_TEXT, GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, musterAgb, musterWiderrufsbelehrung, musterWiderrufsformular, BESTAETIGUNG_TEXT, GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, musterAgb, musterWiderrufsbelehrung, musterWiderrufsformular,
normalisiereRechtstexte, pruefeBestaetigbar, rechtstexteHash, versandUndZahlung, vollstaendig, normalisiereRechtstexte, pruefeBestaetigbar, rechtstexteHash, versandUndZahlung, vollstaendig,
@@ -434,6 +435,15 @@ router.post('/rechtstexte/bestaetigen', requireSession, nurKunde, limitWrites, r
} }
const at = new Date().toISOString() const at = new Date().toISOString()
const bestaetigt = { texte: { ...entwurf }, hash, at, von } 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 }) 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.') 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, { 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 * - 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 * (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). * 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 * 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 * 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). * 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 || []) { for (const id of entry.consentIds || []) {
if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1 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 let shopRest = SHOP_LOESCH_DECKEL
for (const shop of entry.shops || []) { for (const shop of entry.shops || []) {
await attempt('shopConfig', shop.id, async () => { 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' 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 * 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. * (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. * Das Stripe-Konto gehoert dem Haendler und wird nie angefasst.
@@ -17,17 +17,18 @@ const GRUPPEN = [
['shopOrders', 'bestellungen'], ['shopOrders', 'bestellungen'],
['shopProducts', 'artikel'], ['shopProducts', 'artikel'],
['shopWiderrufe', 'widerrufe'], ['shopWiderrufe', 'widerrufe'],
['shopRechtstexteArchiv', 'archiv'],
] ]
/** /**
* @param {{ shopId: string, siteKey?: string, deckel?: number, nochGueltig?: () => Promise<boolean> }} opt * @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) * 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, geloescht: number, konfig: boolean, vollstaendig: boolean, abgebrochen: boolean }>} * @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 * vollstaendig = alle Dokumente, Bilder und das shopConfig-Dokument sind weg
*/ */
export async function loescheShopDaten({ shopId, siteKey, deckel = Number.POSITIVE_INFINITY, nochGueltig = async () => true }) { export async function loescheShopDaten({ shopId, siteKey, deckel = Number.POSITIVE_INFINITY, nochGueltig = async () => true }) {
if (typeof shopId !== 'string' || shopId === '') throw new TypeError('loescheShopDaten: shopId fehlt') 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) { for (const [name, feld] of GRUPPEN) {
const sammlung = config.collections[name] const sammlung = config.collections[name]
for (;;) { for (;;) {
+27 -18
View File
@@ -7,6 +7,7 @@ import { pruefeNow, zeitMs } from '../termine/util.js'
import { datumKurz, uhrzeit } from '../termine/zeit.js' import { datumKurz, uhrzeit } from '../termine/zeit.js'
import { euro } from './geld.js' import { euro } from './geld.js'
import { oeffentlicheRechtstexte } from './kontext.js' import { oeffentlicheRechtstexte } from './kontext.js'
import { holeArchivierteRechtstexte } from './archiv.js'
import { gewaehrleistungsHinweis } from './rechtstexte.js' import { gewaehrleistungsHinweis } from './rechtstexte.js'
/** /**
@@ -162,16 +163,17 @@ const datumVon = (now, ...zeitpunkte) => {
* @param {{ doc?: object, shop: object }} ctx * @param {{ doc?: object, shop: object }} ctx
* @param {object|null} order * @param {object|null} order
* @param {Date} [now] Ersatzdatum, wenn die Bestellung keinen Zeitpunkt hat * @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>} * @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') if (!istObjekt(ctx?.shop)) throw new TypeError('kaeuferWerte: ctx.shop fehlt')
const snap = schnappschuss(order) const snap = schnappschuss(order)
const e = einstellungenVon(ctx) const e = einstellungenVon(ctx)
const auszug = istObjekt(snap.einstellungenAuszug) ? snap.einstellungenAuszug : {} const auszug = istObjekt(snap.einstellungenAuszug) ? snap.einstellungenAuszug : {}
const anbieter = anbieterVon(ctx, snap) const anbieter = anbieterVon(ctx, snap)
const abholung = { ...(e.abholung ?? {}), ...(istObjekt(auszug.abholung) ? auszug.abholung : {}) } 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 summen = istObjekt(snap.summen) ? snap.summen : snap
const texte = istObjekt(summen.texte) ? summen.texte : {} const texte = istObjekt(summen.texte) ? summen.texte : {}
const cent = (wert) => (Number.isInteger(wert) ? euro(wert) : LEER) 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), agb: oderLeer(rechtstexte.agb),
widerrufsbelehrung: oderLeer(rechtstexte.widerrufsbelehrung), widerrufsbelehrung: oderLeer(rechtstexte.widerrufsbelehrung),
widerrufsformular: oderLeer(rechtstexte.widerrufsformular), widerrufsformular: oderLeer(rechtstexte.widerrufsformular),
gewaehrleistung: gewaehrleistungsHinweis(), gewaehrleistung: hatText(rechtstexte.gewaehrleistung) ? rechtstexte.gewaehrleistung.trim() : gewaehrleistungsHinweis(),
// fuer G11 (Standard, wenn der Haendler keinen Hinweis hinterlegt hat) // fuer G11 (Standard, wenn der Haendler keinen Hinweis hinterlegt hat)
hinweis: text(abholung.hinweis) || ABHOLUNG_HINWEIS, 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 * G12 name, bestellnummer (Eingaben des Kaeufers; ohne Angabe die der Bestellung), eingang (Date | ISO, Pflicht), nachricht
* G13 betragCent (Pflicht, ganze Cent), grund * G13 betragCent (Pflicht, ganze Cent), grund
*/ */
function kaeuferMail(art, ctx, order, extra, now) { function kaeuferMail(art, ctx, order, extra, now, archiv = null) {
const alle = kaeuferWerte(ctx, order, now) const alle = kaeuferWerte(ctx, order, now, { archiv })
const blocks = {} const blocks = {}
switch (art) { switch (art) {
case 'G09': 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))) } 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 const zuLang = (mail) => mail.html.length > MAX_HTML || mail.text.length > MAX_TEXT
// ---------------------------------------------------------------- Einreihen // ---------------------------------------------------------------- Einreihen
/** /**
* Reiht eine Kaeufermail (G09-G14) in die Warteschlange ein. Empfaenger ist extra.an, sonst order.kaeuferEmail. bookingId ist * 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 * order.$id, sonst extra.bookingId (z. B. die ID des Widerrufs bei G12), sonst "-". G09 enthaelt die Rechtstexte in der Fassung der
* (AGB, Widerrufsbelehrung und Formular koennen zusammen sehr lang sein), ersetzt der Server den AGB-Text durch einen Satz mit * Bestellung (archiv.js, Hash bestellung.rechtstexteHash); fehlt sie im Archiv, gilt mit Warnung die aktuell bestaetigte. Ist die
* Link auf die AGB im Shop; Widerrufsbelehrung, Formular und Gewaehrleistungshinweis bleiben immer vollstaendig. * 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 {'G09'|'G10'|'G11'|'G12'|'G13'|'G14'} art
* @param {{ doc: object, shop: object }} ctx Shop-Kontext (kontext.js) * @param {{ doc: object, shop: object }} ctx Shop-Kontext (kontext.js)
* @param {object|null} order shopOrders-Dokument (bei G12 ohne passende Bestellung null) * @param {object|null} order shopOrders-Dokument (bei G12 ohne passende Bestellung null)
* @param {object} [extra] siehe kaeuferMail * @param {object} [extra] siehe kaeuferMail
* @param {Date} [now] * @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()) { export async function anKaeufer(art, ctx, order, extra = {}, now = new Date()) {
try { try {
if (!GAST_ARTEN.includes(art)) throw new TypeError(`${String(art).slice(0, 20)} ist keine Käufermail`) if (!GAST_ARTEN.includes(art)) throw new TypeError(`${String(art).slice(0, 20)} ist keine Käufermail`)
pruefeNow(now, 'anKaeufer') pruefeNow(now, 'anKaeufer')
extra = extra ?? {} 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) const an = hatText(extra.an) ? extra.an.trim() : text(order?.kaeuferEmail)
if (an === '') throw new TypeError('Empfänger fehlt') if (an === '') throw new TypeError('Empfänger fehlt')
let gekuerzt = false // Nie ein Link statt der Texte: die Bestaetigung muss den Vertragsinhalt selbst enthalten (§ 312f Abs. 2 BGB). Passt die Mail nicht
let mail = renderMail(art, values, blocks) // in die Warteschlange, scheitert sie hier; der Aufrufer behaelt den Marker offen, zaehlt den Versuch und wiederholt.
if (art === 'G09' && zuLang(mail)) { const mail = renderMail(art, values, blocks)
values.agb = `Die vollständigen AGB finden Sie unter ${shopSeite(ctx)}?ansicht=agb`
mail = renderMail(art, values, blocks)
gekuerzt = true
}
if (zuLang(mail)) throw new TypeError('Die Mail ist zu lang für die Warteschlange') if (zuLang(mail)) throw new TypeError('Die Mail ist zu lang für die Warteschlange')
const e = einstellungenVon(ctx) const e = einstellungenVon(ctx)
@@ -306,7 +315,7 @@ export async function anKaeufer(art, ctx, order, extra = {}, now = new Date()) {
}, },
now, now,
) )
return { ok: true, eintrag, gekuerzt } return { ok: true, eintrag }
} catch (err) { } catch (err) {
console.warn(`[shop-mails] ${String(art).slice(0, 20)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`) 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.' } 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 = 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.' '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 // Grenzen so gewaehlt, dass die Bestaetigungs-E-Mail G09 mit allen drei Texten in die Warteschlange passt (html 100000, text 50000;
export const MAX_WIDERRUFSBELEHRUNG = 20000 // die Vorlage braucht rund 12000 Zeichen) und der Volltext nie durch einen Link ersetzt werden muss (§ 312f Abs. 2 BGB).
export const MAX_WIDERRUFSFORMULAR = 5000 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 const MAX_URL = 500
/** Erste Zeile des vorbefuellten Muster-AGB: der Haendler muss sie nach der Pruefung loeschen. */ /** Erste Zeile des vorbefuellten Muster-AGB: der Haendler muss sie nach der Pruefung loeschen. */
export const MUSTER_KOPFZEILE = 'Muster – bitte prüfen und anpassen' export const MUSTER_KOPFZEILE = 'Muster – bitte prüfen und anpassen'
@@ -230,6 +234,20 @@ export function gewaehrleistungsHinweis() {
].join('\n\n') ].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 -------------------------------------------------------------------------------------------------------- // --- Normalisierung --------------------------------------------------------------------------------------------------------
// Steuerzeichen (ausser Zeilenumbruch) und unsichtbare Formatzeichen raus // Steuerzeichen (ausser Zeilenumbruch) und unsichtbare Formatzeichen raus
@@ -300,6 +318,10 @@ export function normalisiereRechtstexte(eingabe) {
out[key] = '' out[key] = ''
} else out[key] = r.wert } 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 } if (Object.keys(fehler).length) return { fehler }
return { return {
rechtstexte: { 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(), * 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 * 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 * 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')], 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. */ /** 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'), shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'), shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'), shopWiderrufe: schemaKeys('shopWiderrufe'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
}, },
}) })
const st = await startFakeStripe() 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}_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('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('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) const ordner = path.join(uploadDir, 'shop', siteKey)
mkdirSync(ordner, { recursive: true }) mkdirSync(ordner, { recursive: true })
writeFileSync(path.join(ordner, 'bild-l.webp'), 'x') 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.failed, [])
assert.deepEqual(result.held, []) assert.deepEqual(result.held, [])
assert.equal(result.deleted.shops, 1) assert.equal(result.deleted.shops, 1)
assert.equal(result.deleted.shopDocs, 4) assert.equal(result.deleted.shopDocs, 5)
for (const [col, ids] of [['shopOrders', ['sh1_ord1', 'sh1_ord2']], ['shopProducts', ['sh1_prod']], ['shopWiderrufe', ['sh1_wid']], ['shopConfig', ['f_sh1_shop']]]) { 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`) 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(!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 // Reihenfolge: Shopdaten vor den Vertragsereignissen und Abo-Dokumenten
assert.ok(reihenfolge.lastIndexOf('shopConfig') < reihenfolge.indexOf('contractEvents')) assert.ok(reihenfolge.lastIndexOf('shopConfig') < reihenfolge.indexOf('contractEvents'))
assert.ok(reihenfolge.indexOf('contractEvents') < reihenfolge.indexOf('subscriptions')) 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 () => { 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'), shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'), shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'), shopOrders: schemaKeys('shopOrders'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
termineOutbox: schemaKeys('termineOutbox'), termineOutbox: schemaKeys('termineOutbox'),
}, },
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES }, 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 { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const K = await import('../server/services/shop/kontext.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 B = await import('../server/services/shop/bestellungen.js')
const J = await import('../server/services/shop/jobs.js') const J = await import('../server/services/shop/jobs.js')
const { normalisiereProdukt } = await import('../server/services/shop/produkte.js') const { normalisiereProdukt } = await import('../server/services/shop/produkte.js')
@@ -87,7 +89,7 @@ let sink
let log let log
let ctx let ctx
async function aufbau({ produkte = [artikel('a1', { bestand: 5 })], shop = {} } = {}) { 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) 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('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') 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 () => { test('schliesseAb: idempotent - zweiter Aufruf ändert nichts, keine zweite Mail, Bestand nur einmal', async () => {
await aufbau() await aufbau()
const b = await bestelle() 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 { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js' import { useTestEnv } from './support/testEnv.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.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 // 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. // (termineOutbox), Händlermails gehen direkt an das Kundenkonto. Mails landen in der Senke, nichts verlässt den Prozess.
const aw = await startAppwriteMock({ const aw = await startAppwriteMock({
schema: { termineOutbox: schemaKeys('termineOutbox') }, schema: { termineOutbox: schemaKeys('termineOutbox'), shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv') },
datetimes: TERMINE_DATETIMES, datetimes: { ...TERMINE_DATETIMES, ...SHOP_DATETIMES },
defaultLimit: 25, defaultLimit: 25,
}) })
after(() => aw.close()) 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 { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { gewaehrleistungsHinweis } = await import('../server/services/shop/rechtstexte.js') const { gewaehrleistungsHinweis } = await import('../server/services/shop/rechtstexte.js')
const M = await import('../server/services/shop/mails.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 const { anKaeufer, anHaendler, kaeuferWerte, haendlerK25, haendlerK26 } = M
after(() => setMailTransportForTests(null)) after(() => setMailTransportForTests(null))
@@ -89,6 +92,7 @@ let sink
let log let log
beforeEach(async () => { beforeEach(async () => {
for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(config.collections.termineOutbox, id) 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) for (const id of aw.docs('customers').keys()) await deleteDocument(config.collections.customers, id)
resetOutboxForTests() resetOutboxForTests()
config.smtp.from = 'noreply@webklar.com' 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 () => { 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) const erg = await anKaeufer('G09', ctxMit(), bestellung(), {}, JETZT)
assert.equal(erg.ok, true) assert.equal(erg.ok, true)
assert.equal(erg.gekuerzt, false)
await warteAufVersand() await warteAufVersand()
const [doc] = eintraege() const [doc] = eintraege()
@@ -222,30 +225,78 @@ test('G09 Abholung: Abholort statt Lieferadresse', async () => {
assert.deepEqual(warnLog(), []) 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 () => { /** Zeilen mit Satzzeichen und Sonderzeichen wie in echten Rechtstexten, genau n Zeichen lang. */
const lang = 'Dies ist ein langer Satz der Geschäftsbedingungen.\n'.repeat(560) // etwa 28 000 Zeichen const textMitLaenge = (n, zeile) => {
const t = texte({ agb: lang, widerrufsbelehrung: 'Belehrung Satz.\n'.repeat(1000), widerrufsformular: 'Formular Zeile\n'.repeat(300) }) let t = ''
const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung(), {}, JETZT) 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.ok, true)
assert.equal(erg.gekuerzt, true)
const doc = eintraege()[0] const doc = eintraege()[0]
assert.ok(doc.html.length <= 100000 && doc.text.length <= 50000, `${doc.html.length}/${doc.text.length}`) 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.equal(doc.text.split('Ein Satz der Geschäftsbedingungen').length - 1 >= 300, true, 'alle Texte stehen vollständig in der Mail')
assert.doesNotMatch(doc.text, /langer Satz der Geschäftsbedingungen/) assert.ok(doc.text.includes(t.agb.slice(-200).trim().split('\n').pop().slice(0, 30)), 'Ende der AGB')
assert.match(doc.text, /Belehrung Satz\./) assert.doesNotMatch(doc.text, /ansicht=agb|vollständigen AGB finden Sie/)
assert.match(doc.text, /Formular Zeile/)
assert.match(doc.text, /GESETZLICHE GEWÄHRLEISTUNG/)
}) })
test('G09: auch gekürzt zu lang ⇒ { ok: false } ohne Wurf und ohne Eintrag', async () => { test('G09 I-7: zu lang für die Warteschlange ⇒ { ok: false } ohne Wurf, ohne Eintrag und ohne Link-Ersatz', async () => {
const t = texte({ widerrufsbelehrung: 'x'.repeat(60000) }) 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) 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.deepEqual(erg, { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' })
assert.equal(eintraege().length, 0) assert.equal(eintraege().length, 0)
assert.equal(sink.sent.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/) 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 () => { 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(), { versanddienst: 'DHL', sendungsnummer: '00340434' }, JETZT)).ok, true)
assert.equal((await anKaeufer('G10', ctxMit(), bestellung({ $id: 'o_zwei' }), {}, 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, BESTAETIGUNG_TEXT,
pruefeBestaetigbar, pruefeBestaetigbar,
MUSTER_KOPFZEILE, MUSTER_KOPFZEILE,
MAX_AGB,
MAX_WIDERRUFSBELEHRUNG,
MAX_WIDERRUFSFORMULAR,
maskierteLaenge,
} from '../server/services/shop/rechtstexte.js' } from '../server/services/shop/rechtstexte.js'
import { STANDARD_EINSTELLUNGEN } from '../server/services/shop/einstellungen.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) assert.equal(vollstaendig(leer.rechtstexte), false)
}) })
test('normalisiereRechtstexte: Laengen', () => { test('normalisiereRechtstexte: Laengen (I-7: so kurz, dass die Bestätigungsmail nie Links statt Texte braucht)', () => {
assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(30000) }).rechtstexte) assert.deepEqual([MAX_AGB, MAX_WIDERRUFSBELEHRUNG, MAX_WIDERRUFSFORMULAR], [20000, 12000, 3000])
assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(30001) }).fehler.agb) assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(20000) }).rechtstexte)
assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(20000) }).rechtstexte) assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(20001) }).fehler.agb)
assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(20001) }).fehler.widerrufsbelehrung) assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(12000) }).rechtstexte)
assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(5000) }).rechtstexte) assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(12001) }).fehler.widerrufsbelehrung)
assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(5001) }).fehler.widerrufsformular) 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', () => { test('normalisiereRechtstexte: URLs nur https ohne Zugangsdaten und Leerraum', () => {
+14 -2
View File
@@ -18,6 +18,7 @@ const aw = await startAppwriteMock({
portalFeatures: schemaKeys('portalFeatures'), portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'), shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'), shopProducts: schemaKeys('shopProducts'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
customerViewLog: schemaKeys('customerViewLog'), customerViewLog: schemaKeys('customerViewLog'),
}, },
datetimes: SHOP_DATETIMES, datetimes: SHOP_DATETIMES,
@@ -32,6 +33,7 @@ const { kundenansichtGuard } = await import('../server/middleware/kundenansichtG
const sf = await import('../server/services/siteFeatures.js') const sf = await import('../server/services/siteFeatures.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { shopPerSiteKey } = await import('../server/services/shop/kontext.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 { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
@@ -117,7 +119,7 @@ beforeEach(async () => {
config.shop.mode = 'pilot' config.shop.mode = 'pilot'
config.shop.pilotEmails = [customers.c1.email] config.shop.pilotEmails = [customers.c1.email]
resetShopRoutesForTests() 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) 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 }) 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.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', { rechtstexte: 'text' })).status, 400)
assert.equal((await schreiben('PUT', 'rechtstexte', {})).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(zuLang.status, 400)
assert.equal((await lesen('rechtstexte')).body.entwurf.agb, 'Neue AGB\n\nZeile', 'Fehler aendert nichts') 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.entwurf, texteFuer(feature))
assert.deepEqual([gespeichert.bestaetigt.texte, gespeichert.bestaetigt.hash], [texteFuer(feature), voll.body.hash]) 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') 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 // 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' } }) 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') const { createDocument, updateDocument, getDocument } = await import('../server/services/appwriteAdmin.js')
after(() => Promise.all([aw.close(), st.close()])) 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) 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], [36, 36, 32, 200, 320, 2000, 36],
) )
for (const key of ['eingangAt', 'bestaetigungAt', 'bearbeitetAt']) assert.equal(widerrufe[key].type, 'datetime', key) 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)', () => { 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'], ['idx_erstellt', 'shopId', 'erstelltAt'],
]) ])
assert.deepEqual(indexes('shopWiderrufe'), [['idx_shop', 'shopId', 'eingangAt']]) assert.deepEqual(indexes('shopWiderrufe'), [['idx_shop', 'shopId', 'eingangAt']])
assert.deepEqual(indexes('shopRechtstexteArchiv'), [['idx_shop_hash', 'shopId', 'hash']])
for (const name of NAMES) { for (const name of NAMES) {
for (const index of SHOP_COLLECTIONS[name].indexes) { for (const index of SHOP_COLLECTIONS[name].indexes) {
assert.equal(index.type, 'key') assert.equal(index.type, 'key')
@@ -174,6 +181,7 @@ test('Schema: SHOP_DATETIMES nennt je Collection genau die datetime-Attribute',
shopProducts: [], shopProducts: [],
shopOrders: ['erstelltAt', 'ablaufAt', 'bezahltAt', 'anonymisiertAt'], shopOrders: ['erstelltAt', 'ablaufAt', 'bezahltAt', 'anonymisiertAt'],
shopWiderrufe: ['eingangAt', 'bestaetigungAt', 'bearbeitetAt'], 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) 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 client = fakeClient()
const result = await main({ client, log: quiet }) const result = await main({ client, log: quiet })
assert.deepEqual(client.calls.collections, NAMES.map((id) => ({ id, name: id }))) 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.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.attrs.filter((k) => k.startsWith('shopWiderrufe.')), ['shopWiderrufe.bearbeitetAt', 'shopWiderrufe.orderId'])
assert.deepEqual(client.calls.indexes.filter((k) => k.startsWith('shopWiderrufe.')), ['shopWiderrufe.idx_shop']) 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 () => { test('main --check legt nichts an und meldet Fehlendes', async () => {