diff --git a/server/services/shop/einstellungen.js b/server/services/shop/einstellungen.js new file mode 100644 index 0000000..109ea93 --- /dev/null +++ b/server/services/shop/einstellungen.js @@ -0,0 +1,330 @@ +import { config } from '../../config.js' +import { getDocument, updateDocument } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' + +/** + * Shop-Einstellungen (Plan 2026-10-05-webshop.md, Task C2; shopConfig.einstellungen als JSON-Text). + * Normalisieren ist rein: Eingabe => { einstellungen } mit frischem, vollstaendigem Objekt oder { fehler: { 'feld.pfad': 'Text' } } + * (Texte in Du-Form, das Portal spricht den Haendler an). Leere Pflichtangaben sind beim Normalisieren erlaubt, damit der Haendler + * Schritt fuer Schritt speichern kann; ob alles da ist, sagt istVollstaendig(). Geld ist immer ganze Cent. + */ + +const ZAHLUNGSARTEN = ['card', 'klarna', 'sepa_debit'] +const LAENDER = ['DE', 'AT'] +const MAX_VERSAND_CENT = 100_000 +const MAX_CENT = 10_000_000 +const PLZ_RE = { DE: /^\d{5}$/, AT: /^\d{4}$/ } +const USTID_RE = /^(DE\d{9}|ATU\d{8})$/ +const AKZENT_RE = /^#[0-9a-fA-F]{6}$/ + +const tief = (wert) => { + if (Array.isArray(wert)) return Object.freeze(wert.map(tief)) + if (wert && typeof wert === 'object') return Object.freeze(Object.fromEntries(Object.entries(wert).map(([k, v]) => [k, tief(v)]))) + return wert +} + +export const STANDARD_EINSTELLUNGEN = tief({ + shopname: '', + anbieter: { name: '', strasse: '', plz: '', ort: '', land: 'DE', email: '', telefon: '', ustId: '' }, + kleinunternehmer: true, + versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null }, + abholung: { aktiv: false, adresse: '', hinweis: '' }, + lieferzeit: '2–4 Werktage', + zahlungsarten: ['card'], + akzent: '#111111', + mindestbestellCent: null, + ruecksendekosten: 'kaeufer', +}) + +/** Tiefe, veraenderbare Kopie (JSON-tauglich). */ +const kopie = (wert) => JSON.parse(JSON.stringify(wert)) + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +/** Nur eigene Schluessel lesen, nie aus der Prototypkette (zB __proto__ aus JSON.parse bleibt ein eigener Schluessel, wird aber nie gelesen). */ +const hole = (objekt, schluessel) => (istObjekt(objekt) && Object.hasOwn(objekt, schluessel) ? objekt[schluessel] : undefined) + +// Steuerzeichen und unsichtbare Formatzeichen (Zero-Width, Richtungsmarken, BOM, Soft Hyphen) sowie Zeilen-/Absatztrenner +const STEUERZEICHEN = /[\p{Cc}\p{Cf}\u2028\u2029]/gu + +/** Bereinigter Text: ohne Steuerzeichen, getrimmt; einzeilig macht aus Umbruechen Leerzeichen, mehrzeilig behaelt einzelne Umbrueche. */ +function bereinige(wert, { mehrzeilig = false } = {}) { + if (typeof wert !== 'string') return '' + let s = wert.replace(/\r\n?/g, '\n').replace(/\t/g, ' ') + s = mehrzeilig ? s.replace(STEUERZEICHEN, (z) => (z === '\n' ? z : '')).replace(/[ ]*\n[ ]*/g, '\n').replace(/\n{3,}/g, '\n\n') : s.replace(/\n+/g, ' ').replace(STEUERZEICHEN, '').replace(/ {2,}/g, ' ') + return s.trim() +} + +const laenge = (s) => [...s].length + +/** Ein Postfach: kein Leerraum, genau ein @, keine ,;<>" und mindestens ein Punkt in der Domain, hoechstens 254 Zeichen. */ +function emailAdresse(wert) { + const s = bereinige(wert).toLowerCase() + if (s.length === 0 || s.length > 254) return null + return /^[^\s@,;<>"]+@[^\s@,;<>".]+(\.[^\s@,;<>".]+)+$/.test(s) ? s : null +} + +/** + * Pruefen und Normalisieren. Fehlende Felder bekommen die Standardwerte, unbekannte Schluessel werden ignoriert, + * das Ergebnis teilt keine Referenzen mit der Eingabe. + * @returns {{einstellungen: object} | {fehler: Object}} + */ +export function normalisiereEinstellungen(eingabe) { + if (!istObjekt(eingabe)) return { fehler: { einstellungen: 'Die Einstellungen fehlen oder sind unlesbar.' } } + const fehler = {} + const fehl = (pfad, text) => { + if (!(pfad in fehler)) fehler[pfad] = text + } + const std = STANDARD_EINSTELLUNGEN + + /** Textfeld bis max Zeichen; leer erlaubt, ausser min > 0 (dann leer oder zu kurz = Fehler nur bei nicht leerem Text). */ + const textFeld = (pfad, wert, { max, min = 0, mehrzeilig = false, vorgabe = '', name }) => { + if (wert === undefined || wert === null) return vorgabe + if (typeof wert !== 'string') { + fehl(pfad, `Bitte gib ${name} als Text an.`) + return vorgabe + } + const s = bereinige(wert, { mehrzeilig }) + if (laenge(s) > max) { + fehl(pfad, `${name[0].toUpperCase()}${name.slice(1)} darf höchstens ${max} Zeichen lang sein.`) + return vorgabe + } + if (s !== '' && laenge(s) < min) { + fehl(pfad, `${name[0].toUpperCase()}${name.slice(1)} muss mindestens ${min} Zeichen lang sein.`) + return vorgabe + } + return s + } + const wahrheit = (pfad, wert, vorgabe, name) => { + if (wert === undefined || wert === null) return vorgabe + if (typeof wert !== 'boolean') { + fehl(pfad, `Bitte wähle bei „${name}“ ja oder nein.`) + return vorgabe + } + return wert + } + /** null oder ganze Zahl 1..max (Cent). */ + const centOderNull = (pfad, wert, name) => { + if (wert === undefined || wert === null) return null + if (!Number.isSafeInteger(wert) || wert <= 0 || wert > MAX_CENT) { + fehl(pfad, `${name} muss ein Betrag über 0 € (in ganzen Cent) sein – oder leer, wenn es keinen geben soll.`) + return null + } + return wert + } + + const shopname = textFeld('shopname', hole(eingabe, 'shopname'), { max: 80, name: 'der Shopname' }) + + // Anbieter + const a = hole(eingabe, 'anbieter') + const land = (() => { + const w = hole(a, 'land') + if (w === undefined || w === null) return std.anbieter.land + if (!LAENDER.includes(w)) { + fehl('anbieter.land', 'Bitte wähle Deutschland oder Österreich als Land deines Unternehmens.') + return std.anbieter.land + } + return w + })() + const anbieter = { + name: textFeld('anbieter.name', hole(a, 'name'), { max: 120, name: 'der Name des Anbieters' }), + strasse: textFeld('anbieter.strasse', hole(a, 'strasse'), { max: 120, name: 'die Straße' }), + plz: '', + ort: textFeld('anbieter.ort', hole(a, 'ort'), { max: 80, name: 'der Ort' }), + land, + email: '', + telefon: textFeld('anbieter.telefon', hole(a, 'telefon'), { max: 40, name: 'die Telefonnummer' }), + ustId: '', + } + const plzRoh = hole(a, 'plz') + if (plzRoh !== undefined && plzRoh !== null) { + const plz = bereinige(plzRoh) + if (plz === '') anbieter.plz = '' + else if (PLZ_RE[land].test(plz)) anbieter.plz = plz + else fehl('anbieter.plz', land === 'AT' ? 'Bitte gib eine österreichische Postleitzahl mit 4 Ziffern an.' : 'Bitte gib eine deutsche Postleitzahl mit 5 Ziffern an.') + } + const emailRoh = hole(a, 'email') + if (emailRoh !== undefined && emailRoh !== null && bereinige(emailRoh) !== '') { + const mail = emailAdresse(emailRoh) + if (mail) anbieter.email = mail + else fehl('anbieter.email', 'Bitte gib genau eine gültige E-Mail-Adresse an (höchstens 254 Zeichen).') + } + const ustRoh = hole(a, 'ustId') + if (ustRoh !== undefined && ustRoh !== null) { + const ust = bereinige(ustRoh).replace(/\s+/g, '').toUpperCase() + if (ust === '') anbieter.ustId = '' + else if (USTID_RE.test(ust)) anbieter.ustId = ust + else fehl('anbieter.ustId', 'Die USt-IdNr. sieht so aus: DE123456789 oder ATU12345678. Lass das Feld leer, wenn du keine hast.') + } + + const kleinunternehmer = wahrheit('kleinunternehmer', hole(eingabe, 'kleinunternehmer'), std.kleinunternehmer, 'Kleinunternehmer') + + // Versand + const v = hole(eingabe, 'versand') + const versandAktiv = wahrheit('versand.aktiv', hole(v, 'aktiv'), std.versand.aktiv, 'Versand anbieten') + let laender = [...std.versand.laender] + const laenderRoh = hole(v, 'laender') + if (laenderRoh !== undefined && laenderRoh !== null) { + const menge = Array.isArray(laenderRoh) ? new Set(laenderRoh) : null + if (menge && menge.has('DE') && [...menge].every((l) => LAENDER.includes(l))) laender = LAENDER.filter((l) => menge.has(l)) + else fehl('versand.laender', 'Du kannst nach Deutschland oder nach Deutschland und Österreich liefern. Deutschland ist immer dabei.') + } + const kostenRoh = hole(v, 'kostenCent') + const kostenCent = {} + for (const l of laender) { + const w = hole(kostenRoh, l) + if (w === undefined || w === null) { + if (versandAktiv && l !== 'DE') fehl(`versand.kostenCent.${l}`, `Bitte gib die Versandkosten für ${l === 'AT' ? 'Österreich' : 'Deutschland'} an.`) + kostenCent[l] = l === 'DE' ? std.versand.kostenCent.DE : 990 + } else if (!Number.isSafeInteger(w) || w < 0 || w > MAX_VERSAND_CENT) { + fehl(`versand.kostenCent.${l}`, `Die Versandkosten für ${l === 'AT' ? 'Österreich' : 'Deutschland'} müssen zwischen 0 € und 1.000 € liegen (in ganzen Cent).`) + kostenCent[l] = l === 'DE' ? std.versand.kostenCent.DE : 990 + } else kostenCent[l] = w + } + const freiAbCent = centOderNull('versand.freiAbCent', hole(v, 'freiAbCent'), 'Die Grenze für versandkostenfreie Lieferung') + + // Abholung + const ab = hole(eingabe, 'abholung') + const abholungAktiv = wahrheit('abholung.aktiv', hole(ab, 'aktiv'), std.abholung.aktiv, 'Abholung anbieten') + const abholung = { + aktiv: abholungAktiv, + adresse: textFeld('abholung.adresse', hole(ab, 'adresse'), { max: 300, mehrzeilig: true, name: 'die Abholadresse' }), + hinweis: textFeld('abholung.hinweis', hole(ab, 'hinweis'), { max: 300, mehrzeilig: true, name: 'der Abholhinweis' }), + } + if (abholungAktiv && abholung.adresse === '' && !('abholung.adresse' in fehler)) fehl('abholung.adresse', 'Bitte gib an, wo die Ware abgeholt werden kann.') + if (!versandAktiv && !abholungAktiv && !('versand.aktiv' in fehler) && !('abholung.aktiv' in fehler)) { + fehl('versand.aktiv', 'Bitte biete Versand oder Abholung an – mindestens eins von beiden muss aktiv sein.') + } + + const lieferzeit = textFeld('lieferzeit', hole(eingabe, 'lieferzeit'), { max: 80, min: 3, vorgabe: std.lieferzeit, name: 'die Lieferzeit' }) + + // Zahlungsarten: card immer dabei + let zahlungsarten = ['card'] + const zahlRoh = hole(eingabe, 'zahlungsarten') + if (zahlRoh !== undefined && zahlRoh !== null) { + if (Array.isArray(zahlRoh) && zahlRoh.every((z) => ZAHLUNGSARTEN.includes(z))) { + const menge = new Set(zahlRoh) + zahlungsarten = ZAHLUNGSARTEN.filter((z) => z === 'card' || menge.has(z)) + } else fehl('zahlungsarten', 'Bitte wähle nur Karte, Klarna oder SEPA-Lastschrift als Zahlungsarten. Die Kartenzahlung ist immer dabei.') + } + + let akzent = std.akzent + const akzentRoh = hole(eingabe, 'akzent') + if (akzentRoh !== undefined && akzentRoh !== null) { + if (typeof akzentRoh === 'string' && AKZENT_RE.test(akzentRoh.trim())) akzent = akzentRoh.trim().toLowerCase() + else fehl('akzent', 'Bitte gib die Akzentfarbe als Hex-Wert an, zum Beispiel #111111.') + } + + const mindestbestellCent = centOderNull('mindestbestellCent', hole(eingabe, 'mindestbestellCent'), 'Der Mindestbestellwert') + + let ruecksendekosten = std.ruecksendekosten + const rueckRoh = hole(eingabe, 'ruecksendekosten') + if (rueckRoh !== undefined && rueckRoh !== null) { + if (rueckRoh === 'kaeufer' || rueckRoh === 'haendler') ruecksendekosten = rueckRoh + else fehl('ruecksendekosten', 'Bitte wähle, ob die Käuferin bzw. der Käufer oder du die Kosten der Rücksendung trägst.') + } + + if (Object.keys(fehler).length) return { fehler } + return { + einstellungen: { + shopname, + anbieter, + kleinunternehmer, + versand: { aktiv: versandAktiv, laender, kostenCent, freiAbCent }, + abholung, + lieferzeit, + zahlungsarten, + akzent, + mindestbestellCent, + ruecksendekosten, + }, + } +} + +/** Alles Noetige zum Verkaufen: Normalisierung ohne Fehler und Pflichtangaben vorhanden. */ +export function istVollstaendig(einstellungen) { + const r = normalisiereEinstellungen(einstellungen) + if (r.fehler) return false + const e = r.einstellungen + return [e.shopname, e.anbieter.name, e.anbieter.strasse, e.anbieter.plz, e.anbieter.ort, e.anbieter.email, e.lieferzeit].every((s) => s !== '') +} + +/** Standardwerte, darueber die bekannten Felder aus gespeichertem Stand (eine Ebene tief fuer die Unterobjekte). */ +function mitStandard(roh) { + const e = kopie(STANDARD_EINSTELLUNGEN) + if (!istObjekt(roh)) return e + for (const key of Object.keys(e)) { + const w = hole(roh, key) + if (w === undefined) continue + if (['anbieter', 'abholung'].includes(key)) { + if (istObjekt(w)) for (const k of Object.keys(e[key])) if (hole(w, k) !== undefined) e[key][k] = kopie(w[k]) + } else if (key === 'versand') { + if (istObjekt(w)) { + for (const k of Object.keys(e.versand)) if (hole(w, k) !== undefined) e.versand[k] = kopie(w[k]) + } + } else e[key] = kopie(w) + } + return e +} + +/** + * Einstellungen aus einem shopConfig-Dokument. Fehlt der JSON-Text oder ist er kaputt, gelten die Standardwerte. + * Ein lesbarer, aber ungueltiger Stand wird nicht verworfen, sondern mit den Standardwerten aufgefuellt zurueckgegeben + * (istVollstaendig() / normalisiereEinstellungen() melden dann die Fehler). + */ +export function ladeEinstellungen(shopDoc) { + let roh = null + try { + roh = typeof shopDoc?.einstellungen === 'string' && shopDoc.einstellungen !== '' ? JSON.parse(shopDoc.einstellungen) : null + } catch { + roh = null + } + const gefuellt = mitStandard(roh) + const r = normalisiereEinstellungen(gefuellt) + return r.einstellungen || gefuellt +} + +// ---------------------------------------------------------------- Speichern + +const hooks = new Set() + +/** Meldet eine Funktion an, die nach jedem Speichern mit der Shop-ID gerufen wird (zB Cache leeren). Rueckgabe: Abmelden. */ +export function nachSpeichern(fn) { + if (typeof fn !== 'function') throw new TypeError('nachSpeichern: Funktion erwartet') + hooks.add(fn) + return () => hooks.delete(fn) +} + +function rufeHooks(shopId) { + for (const fn of [...hooks]) { + try { + Promise.resolve(fn(shopId)).catch(() => {}) + } catch { + /* Fehler im Hook duerfen das Speichern nicht kippen */ + } + } +} + +const veraltet = () => + Object.assign(new Error('Die Einstellungen wurden inzwischen geändert. Bitte lade die Seite neu.'), { status: 409, code: 'EINSTELLUNG_VERALTET' }) + +/** + * Speichert die Einstellungen eines Shops (Dokument-ID = shopId) und erhoeht `version`. + * Mit erwarteteVersion (ganze Zahl; 0 = noch nie gespeichert) wird gegen gleichzeitige Aenderungen geprueft: Abweichung => Fehler 409. + * Ungueltige Einstellungen => Fehler 400 mit `fehler` (Feld => Text), unbekannter Shop => 404 aus Appwrite. + * @returns {Promise<{einstellungen: object, version: number}>} + */ +export async function speichereEinstellungen(shopId, einstellungen, { erwarteteVersion } = {}) { + if (typeof shopId !== 'string' || shopId === '') throw new TypeError('shopId fehlt') + const r = normalisiereEinstellungen(einstellungen) + if (r.fehler) throw Object.assign(new Error('Die Einstellungen sind nicht gültig.'), { status: 400, fehler: r.fehler }) + const ergebnis = await withLocks([`shop-cfg:${shopId}`], async () => { + const col = config.collections.shopConfig + const doc = await getDocument(col, shopId) + const aktuell = Number.isInteger(doc?.version) && doc.version > 0 ? doc.version : 0 + if (erwarteteVersion !== undefined && erwarteteVersion !== null && erwarteteVersion !== aktuell) throw veraltet() + await updateDocument(col, shopId, { einstellungen: JSON.stringify(r.einstellungen), version: aktuell + 1 }) + return { einstellungen: r.einstellungen, version: aktuell + 1 } + }) + rufeHooks(shopId) + return ergebnis +} diff --git a/test/shopEinstellungen.test.js b/test/shopEinstellungen.test.js new file mode 100644 index 0000000..0028a28 --- /dev/null +++ b/test/shopEinstellungen.test.js @@ -0,0 +1,268 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Einstellungen des Shops: reines Normalisieren (Fehler je Feld) und Speichern mit Versionspruefung gegen den Appwrite-Nachbau. +const aw = await startAppwriteMock({ schema: { shopConfig: schemaKeys('shopConfig') }, datetimes: SHOP_DATETIMES }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { createDocument, getDocument } = await import('../server/services/appwriteAdmin.js') +const E = await import('../server/services/shop/einstellungen.js') +const { STANDARD_EINSTELLUNGEN, normalisiereEinstellungen, ladeEinstellungen, speichereEinstellungen, nachSpeichern, istVollstaendig } = E + +const komplett = (over = {}) => ({ + shopname: 'Seifenmanufaktur Mustermann', + anbieter: { + name: 'Erika Mustermann', + strasse: 'Musterstraße 1', + plz: '80331', + ort: 'München', + land: 'DE', + email: 'Erika@Example.com', + telefon: '089 123456', + ustId: '', + }, + kleinunternehmer: true, + versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null }, + abholung: { aktiv: false, adresse: '', hinweis: '' }, + lieferzeit: '2–4 Werktage', + zahlungsarten: ['card'], + akzent: '#111111', + mindestbestellCent: null, + ruecksendekosten: 'kaeufer', + ...over, +}) +const fehler = (eingabe) => normalisiereEinstellungen(eingabe).fehler +const ok = (eingabe) => { + const r = normalisiereEinstellungen(eingabe) + assert.equal(r.fehler, undefined, JSON.stringify(r.fehler)) + return r.einstellungen +} + +test('Standardwerte sind eingefroren, so wie im Plan und selbst gueltig', () => { + assert.ok(Object.isFrozen(STANDARD_EINSTELLUNGEN)) + assert.ok(Object.isFrozen(STANDARD_EINSTELLUNGEN.versand)) + assert.ok(Object.isFrozen(STANDARD_EINSTELLUNGEN.anbieter)) + assert.deepEqual(STANDARD_EINSTELLUNGEN, { + shopname: '', + anbieter: { name: '', strasse: '', plz: '', ort: '', land: 'DE', email: '', telefon: '', ustId: '' }, + kleinunternehmer: true, + versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null }, + abholung: { aktiv: false, adresse: '', hinweis: '' }, + lieferzeit: '2–4 Werktage', + zahlungsarten: ['card'], + akzent: '#111111', + mindestbestellCent: null, + ruecksendekosten: 'kaeufer', + }) + assert.deepEqual(ok(STANDARD_EINSTELLUNGEN), STANDARD_EINSTELLUNGEN) + assert.equal(istVollstaendig(STANDARD_EINSTELLUNGEN), false) +}) + +test('komplette Eingabe: normalisiert, E-Mail klein, ohne gemeinsame Referenzen', () => { + const eingabe = komplett() + const e = ok(eingabe) + assert.equal(e.anbieter.email, 'erika@example.com') + assert.notEqual(e.versand, eingabe.versand) + assert.notEqual(e.versand.laender, eingabe.versand.laender) + assert.notEqual(e.versand.kostenCent, eingabe.versand.kostenCent) + assert.notEqual(e.anbieter, eingabe.anbieter) + assert.notEqual(e.zahlungsarten, eingabe.zahlungsarten) + e.versand.laender.push('X') + assert.deepEqual(eingabe.versand.laender, ['DE']) + assert.deepEqual(STANDARD_EINSTELLUNGEN.versand.laender, ['DE']) + assert.equal(istVollstaendig(komplett()), true) +}) + +test('leere und fehlende Eingabe: Standardwerte; keine Objekte: Fehler', () => { + assert.deepEqual(ok({}), STANDARD_EINSTELLUNGEN) + for (const x of [null, undefined, 'x', 5, [], true]) assert.ok(fehler(x)?.einstellungen, String(x)) +}) + +test('unbekannte Schluessel werden ignoriert, auch __proto__', () => { + const e = ok(JSON.parse('{"foo":1,"__proto__":{"x":1},"anbieter":{"zusatz":"a"},"versand":{"y":2}}')) + assert.deepEqual(Object.keys(e).sort(), Object.keys(STANDARD_EINSTELLUNGEN).sort()) + assert.equal(e.anbieter.zusatz, undefined) + assert.equal(e.versand.y, undefined) + assert.equal({}.x, undefined) +}) + +test('Texte: getrimmt, Steuerzeichen entfernt, Laengengrenzen je Feld', () => { + const e = ok(komplett({ shopname: ' Laden\u0000​ Eins \n', abholung: { aktiv: true, adresse: 'Weg 1\r\n80331 München\u0007', hinweis: 'Klingeln' } })) + assert.equal(e.shopname, 'Laden Eins') + assert.equal(e.abholung.adresse, 'Weg 1\n80331 München') + const grenzen = [ + ['shopname', komplett({ shopname: 'a'.repeat(81) })], + ['anbieter.name', komplett({ anbieter: { ...komplett().anbieter, name: 'a'.repeat(121) } })], + ['anbieter.strasse', komplett({ anbieter: { ...komplett().anbieter, strasse: 'a'.repeat(121) } })], + ['anbieter.ort', komplett({ anbieter: { ...komplett().anbieter, ort: 'a'.repeat(81) } })], + ['anbieter.telefon', komplett({ anbieter: { ...komplett().anbieter, telefon: '1'.repeat(41) } })], + ['lieferzeit', komplett({ lieferzeit: 'a'.repeat(81) })], + ['abholung.hinweis', komplett({ abholung: { aktiv: true, adresse: 'Weg 1', hinweis: 'a'.repeat(301) } })], + ['abholung.adresse', komplett({ abholung: { aktiv: true, adresse: 'a'.repeat(301), hinweis: '' } })], + ] + for (const [pfad, eingabe] of grenzen) assert.ok(fehler(eingabe)?.[pfad], pfad) + ok(komplett({ shopname: 'a'.repeat(80), lieferzeit: 'abc' })) + assert.ok(fehler(komplett({ lieferzeit: 'ab' }))?.lieferzeit) + assert.ok(fehler(komplett({ shopname: 5 }))?.shopname) +}) + +test('Anbieter: E-Mail, PLZ je Land, Land, USt-IdNr.', () => { + const an = (over) => komplett({ anbieter: { ...komplett().anbieter, ...over } }) + for (const mail of ['x', 'a@b', 'a b@c.de', 'a@b.de, c@d.de', `${'a'.repeat(250)}@b.de`, 'a@@b.de', '']) { + assert.ok(fehler(an({ email: mail }))?.['anbieter.email'], mail) + } + assert.equal(ok(an({ email: ' A@B.de ' })).anbieter.email, 'a@b.de') + assert.ok(fehler(an({ plz: '8033' }))?.['anbieter.plz']) + assert.ok(fehler(an({ plz: '80a31' }))?.['anbieter.plz']) + assert.equal(ok(an({ land: 'AT', plz: '1010' })).anbieter.land, 'AT') + assert.ok(fehler(an({ land: 'AT', plz: '80331' }))?.['anbieter.plz']) + assert.ok(fehler(an({ land: 'FR' }))?.['anbieter.land']) + assert.equal(ok(an({ ustId: ' de123456789 ' })).anbieter.ustId, 'DE123456789') + assert.equal(ok(an({ ustId: 'ATU12345678' })).anbieter.ustId, 'ATU12345678') + for (const u of ['DE12345678', 'FR123456789', 'ATU1234567', 'DE1234567890']) assert.ok(fehler(an({ ustId: u }))?.['anbieter.ustId'], u) +}) + +test('Versand: Laender, Kosten je Land, Freigrenze', () => { + const vs = (over) => komplett({ versand: { ...komplett().versand, ...over } }) + const at = ok(vs({ laender: ['AT', 'DE'], kostenCent: { DE: 490, AT: 990, FR: 5 } })) + assert.deepEqual(at.versand.laender, ['DE', 'AT']) + assert.deepEqual(at.versand.kostenCent, { DE: 490, AT: 990 }) + assert.ok(fehler(vs({ laender: ['DE', 'AT'], kostenCent: { DE: 490 } }))?.['versand.kostenCent.AT']) + assert.ok(fehler(vs({ kostenCent: { DE: 4.9 } }))?.['versand.kostenCent.DE']) + assert.ok(fehler(vs({ kostenCent: { DE: '490' } }))?.['versand.kostenCent.DE']) + assert.ok(fehler(vs({ kostenCent: { DE: -1 } }))?.['versand.kostenCent.DE']) + assert.ok(fehler(vs({ kostenCent: { DE: 100001 } }))?.['versand.kostenCent.DE']) + assert.equal(ok(vs({ kostenCent: { DE: 0 } })).versand.kostenCent.DE, 0) + assert.equal(ok(vs({ kostenCent: { DE: 100000 } })).versand.kostenCent.DE, 100000) + for (const l of [['AT'], ['DE', 'FR'], [], 'DE', ['DE', 'DE', 'AT', 'CH']]) assert.ok(fehler(vs({ laender: l }))?.['versand.laender'], JSON.stringify(l)) + assert.equal(ok(vs({ freiAbCent: 5000 })).versand.freiAbCent, 5000) + assert.equal(ok(vs({ freiAbCent: null })).versand.freiAbCent, null) + for (const f of [0, -5, 12.5, '5000', Infinity]) assert.ok(fehler(vs({ freiAbCent: f }))?.['versand.freiAbCent'], String(f)) + assert.ok(fehler(vs({ aktiv: 'ja' }))?.['versand.aktiv']) +}) + +test('mindestens Versand oder Abholung; Abholadresse Pflicht', () => { + const nichts = fehler(komplett({ versand: { ...komplett().versand, aktiv: false } })) + assert.ok(nichts?.['versand.aktiv']) + const abholungNur = ok(komplett({ versand: { ...komplett().versand, aktiv: false }, abholung: { aktiv: true, adresse: 'Lager 3', hinweis: 'Mo–Fr' } })) + assert.equal(abholungNur.abholung.adresse, 'Lager 3') + assert.ok(fehler(komplett({ abholung: { aktiv: true, adresse: ' ', hinweis: '' } }))?.['abholung.adresse']) + // inaktive Abholung braucht keine Adresse + ok(komplett({ abholung: { aktiv: false, adresse: '', hinweis: '' } })) +}) + +test('Zahlungsarten: card immer dabei, nur bekannte, feste Reihenfolge', () => { + assert.deepEqual(ok(komplett({ zahlungsarten: ['sepa_debit', 'klarna'] })).zahlungsarten, ['card', 'klarna', 'sepa_debit']) + assert.deepEqual(ok(komplett({ zahlungsarten: ['card', 'card'] })).zahlungsarten, ['card']) + assert.deepEqual(ok(komplett({ zahlungsarten: [] })).zahlungsarten, ['card']) + assert.ok(fehler(komplett({ zahlungsarten: ['card', 'paypal'] }))?.zahlungsarten) + assert.ok(fehler(komplett({ zahlungsarten: 'card' }))?.zahlungsarten) +}) + +test('Akzent, Mindestbestellwert, Ruecksendekosten, Kleinunternehmer', () => { + assert.equal(ok(komplett({ akzent: '#AABBCC' })).akzent, '#aabbcc') + for (const a of ['AABBCC', '#abc', '#12345g', 'red', '#1234567']) assert.ok(fehler(komplett({ akzent: a }))?.akzent, a) + assert.equal(ok(komplett({ mindestbestellCent: 1000 })).mindestbestellCent, 1000) + for (const m of [0, -1, 1.5, '10']) assert.ok(fehler(komplett({ mindestbestellCent: m }))?.mindestbestellCent, String(m)) + assert.equal(ok(komplett({ ruecksendekosten: 'haendler' })).ruecksendekosten, 'haendler') + assert.ok(fehler(komplett({ ruecksendekosten: 'egal' }))?.ruecksendekosten) + assert.equal(ok(komplett({ kleinunternehmer: false })).kleinunternehmer, false) + assert.ok(fehler(komplett({ kleinunternehmer: 'nein' }))?.kleinunternehmer) +}) + +test('mehrere Fehler zugleich, alle Texte in Du-Form ohne Sie', () => { + const f = fehler(komplett({ akzent: 'x', lieferzeit: 'a', zahlungsarten: ['paypal'], anbieter: { ...komplett().anbieter, email: 'x', plz: '1' } })) + assert.deepEqual(Object.keys(f).sort(), ['akzent', 'anbieter.email', 'anbieter.plz', 'lieferzeit', 'zahlungsarten']) + for (const t of Object.values(f)) { + assert.equal(typeof t, 'string') + assert.doesNotMatch(t, /\bSie\b|\bIhre?[mnrs]?\b/) + } +}) + +test('istVollstaendig: Pflichtangaben und gueltige Normalisierung', () => { + assert.equal(istVollstaendig(komplett()), true) + for (const pfad of ['shopname', 'lieferzeit']) assert.equal(istVollstaendig(komplett({ [pfad]: '' })), false, pfad) + for (const feld of ['name', 'strasse', 'plz', 'ort', 'email']) { + assert.equal(istVollstaendig(komplett({ anbieter: { ...komplett().anbieter, [feld]: '' } })), false, feld) + } + assert.equal(istVollstaendig(komplett({ anbieter: { ...komplett().anbieter, telefon: '', ustId: '' } })), true) + assert.equal(istVollstaendig(komplett({ akzent: 'kaputt' })), false) + assert.equal(istVollstaendig(null), false) +}) + +test('ladeEinstellungen: kaputt oder fehlend ergibt Standard, Teilwerte werden aufgefuellt', () => { + for (const doc of [null, undefined, {}, { einstellungen: '' }, { einstellungen: '{kaputt' }, { einstellungen: '[1]' }, { einstellungen: 'null' }, { einstellungen: '"x"' }]) { + assert.deepEqual(ladeEinstellungen(doc), STANDARD_EINSTELLUNGEN) + } + const teil = ladeEinstellungen({ einstellungen: JSON.stringify({ shopname: 'Laden', anbieter: { ort: 'Ulm' }, versand: { freiAbCent: 5000 } }) }) + assert.equal(teil.shopname, 'Laden') + assert.equal(teil.anbieter.ort, 'Ulm') + assert.equal(teil.anbieter.land, 'DE') + assert.equal(teil.versand.freiAbCent, 5000) + assert.deepEqual(teil.versand.kostenCent, { DE: 490 }) + assert.equal(teil.lieferzeit, '2–4 Werktage') + // gespeicherte Werte werden nicht in die Standardwerte geschrieben + teil.versand.laender.push('AT') + assert.deepEqual(STANDARD_EINSTELLUNGEN.versand.laender, ['DE']) + assert.notEqual(ladeEinstellungen(null), STANDARD_EINSTELLUNGEN) +}) + +test('speichereEinstellungen: Version steigt, Versionspruefung, Hooks', async () => { + const id = 'f_p_shop0001_shop' + await createDocument(config.collections.shopConfig, { customerId: 'c1', projectId: 'p1', siteKey: 'abcdefghijklmnop' }, id) + const gerufen = [] + const abmelden = nachSpeichern((shopId) => gerufen.push(shopId)) + nachSpeichern(() => { + throw new Error('Hook kaputt') + }) + nachSpeichern(async () => { + throw new Error('Hook async kaputt') + }) + + const r1 = await speichereEinstellungen(id, komplett(), { erwarteteVersion: 0 }) + assert.equal(r1.version, 1) + assert.equal(r1.einstellungen.anbieter.email, 'erika@example.com') + let doc = await getDocument(config.collections.shopConfig, id) + assert.equal(doc.version, 1) + assert.equal(JSON.parse(doc.einstellungen).shopname, 'Seifenmanufaktur Mustermann') + assert.deepEqual(ladeEinstellungen(doc), r1.einstellungen) + assert.deepEqual(gerufen, [id]) + + const r2 = await speichereEinstellungen(id, komplett({ shopname: 'Neu' })) + assert.equal(r2.version, 2) + await assert.rejects(speichereEinstellungen(id, komplett({ shopname: 'Alt' }), { erwarteteVersion: 1 }), (err) => { + assert.equal(err.status, 409) + assert.equal(err.message, 'Die Einstellungen wurden inzwischen geändert. Bitte lade die Seite neu.') + return true + }) + doc = await getDocument(config.collections.shopConfig, id) + assert.equal(doc.version, 2) + assert.equal(JSON.parse(doc.einstellungen).shopname, 'Neu') + assert.equal(gerufen.length, 2) + + // ungueltige Einstellungen werden nicht gespeichert: 400 mit Feldfehlern + await assert.rejects(speichereEinstellungen(id, komplett({ akzent: 'x' })), (err) => err.status === 400 && Boolean(err.fehler.akzent)) + assert.equal((await getDocument(config.collections.shopConfig, id)).version, 2) + assert.equal(gerufen.length, 2) + + // gleichzeitige Speicherungen mit derselben erwarteten Version: genau eine gewinnt + const rennen = await Promise.allSettled([ + speichereEinstellungen(id, komplett({ shopname: 'A' }), { erwarteteVersion: 2 }), + speichereEinstellungen(id, komplett({ shopname: 'B' }), { erwarteteVersion: 2 }), + ]) + assert.equal(rennen.filter((x) => x.status === 'fulfilled').length, 1) + assert.equal((await getDocument(config.collections.shopConfig, id)).version, 3) + + abmelden() + await speichereEinstellungen(id, komplett({ shopname: 'C' })) + assert.equal(gerufen.length, 3) // A oder B war der dritte Aufruf, danach keiner mehr + + // unbekannter Shop + await assert.rejects(speichereEinstellungen('gibt-es-nicht', komplett()), (err) => err.status === 404) +})