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:
1 parent
2f4f360a21
commit
08d837d690
17 files changed
+288
-65
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,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
@@ -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(/\/$/, ''),
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 (;;) {
|
||||
|
||||
@@ -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.' }
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user