From bea9f3cfba6e21fb082a46bb108fd655ef76e412 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:39:33 +0000 Subject: [PATCH] =?UTF-8?q?Shop=20C3:=20Produkte=20normalisieren,=20K?= =?UTF-8?q?=C3=A4ufer-Sicht=20und=20Speichern=20(shopProducts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/services/shop/produkte.js | 487 +++++++++++++++++++++++++++++++ test/shopProdukte.test.js | 398 +++++++++++++++++++++++++ 2 files changed, 885 insertions(+) create mode 100644 server/services/shop/produkte.js create mode 100644 test/shopProdukte.test.js diff --git a/server/services/shop/produkte.js b/server/services/shop/produkte.js new file mode 100644 index 0000000..524d30e --- /dev/null +++ b/server/services/shop/produkte.js @@ -0,0 +1,487 @@ +import { randomInt } from 'node:crypto' +import { config } from '../../config.js' +import { createDocument, getDocument, updateDocument, deleteDocument, listAllDocuments, Query } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { euro, grundpreisText } from './geld.js' + +/** + * Shop-Artikel (Plan 2026-10-05-webshop.md, Task C3; shopProducts.produkt als JSON-Text). + * Normalisieren ist rein: Eingabe => { produkt } mit frischem Objekt oder { fehler: { 'feld.pfad': 'Du-Text' } }. + * produktOeffentlich liefert nur Anzeigefelder fuer den Kaeufer (Sie-Form entsteht erst im Widget). Geld ist immer ganze Cent. + */ + +const MAX_CENT = 10_000_000 +const MAX_BESTAND = 1_000_000 +const STEUERSAETZE = [0, 7, 19] +const EINHEITEN = ['g', 'kg', 'ml', 'l', 'm', 'm2'] +const LIEFERARTEN = ['beides', 'abholung'] +const WIDERRUFE = ['gesetzlich', 'verderblich', 'individuell', 'hygiene'] +const WERT_ID_RE = /^[a-z0-9]{8}$/ +const BILD_ID_RE = /^[a-z0-9-]{1,64}$/ +const MAX_JSON = 30000 + +const WIDERRUF_TEXTE = Object.freeze({ + verderblich: 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware', + individuell: 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt', + hygiene: 'Vom Widerruf ausgeschlossen: versiegelte Ware aus Hygienegründen, wenn die Versiegelung entfernt wurde', +}) + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +/** Nur eigene Schluessel lesen, nie aus der Prototypkette. */ +const hole = (objekt, schluessel) => (istObjekt(objekt) && Object.hasOwn(objekt, schluessel) ? objekt[schluessel] : undefined) +const laenge = (s) => [...s].length + +// 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() +} + +/** Zufaellige 8-stellige Wert-ID aus [a-z0-9] (kryptografisch, ohne Modulo-Verzerrung). */ +function neueWertId() { + const zeichen = 'abcdefghijklmnopqrstuvwxyz0123456789' + let id = '' + for (let i = 0; i < 8; i += 1) id += zeichen[randomInt(zeichen.length)] + return id +} + +/** Ganze Zahl aus Zahl oder reinem Ziffern-Text, sonst null. */ +function ganzeZahl(wert) { + if (typeof wert === 'number') return Number.isSafeInteger(wert) ? wert : null + if (typeof wert === 'string' && /^\d{1,12}$/.test(wert.trim())) return Number(wert.trim()) + return null +} + +const leer = (wert) => wert === undefined || wert === null || (typeof wert === 'string' && wert.trim() === '') + +/** + * Pruefen und Normalisieren eines Artikels. Unbekannte Schluessel werden ignoriert, das Ergebnis teilt keine Referenzen + * mit der Eingabe. einstellungen bestimmt nur die Steuer (Kleinunternehmer => immer 0 %). + * @returns {{produkt: object} | {fehler: Object}} + */ +export function normalisiereProdukt(eingabe, { einstellungen } = {}) { + if (!istObjekt(eingabe)) return { fehler: { produkt: 'Der Artikel fehlt oder ist unlesbar.' } } + const fehler = {} + const fehl = (pfad, text) => { + if (!(pfad in fehler)) fehler[pfad] = text + } + const kleinunternehmer = einstellungen?.kleinunternehmer === true + + const text = (pfad, wert, { max, min = 0, mehrzeilig = false, name }) => { + if (wert === undefined || wert === null) { + if (min > 0) fehl(pfad, `Bitte gib ${name} an.`) + return '' + } + if (typeof wert !== 'string') { + fehl(pfad, `Bitte gib ${name} als Text an.`) + return '' + } + const s = bereinige(wert, { mehrzeilig }) + if (s.length === 0 && min > 0) fehl(pfad, `Bitte gib ${name} an.`) + else if (laenge(s) > max) fehl(pfad, `${name[0].toUpperCase()}${name.slice(1)} darf höchstens ${max} Zeichen lang sein.`) + return s + } + + const cent = (pfad, wert, name) => { + const n = ganzeZahl(wert) + if (n === null || n <= 0 || n > MAX_CENT) { + fehl(pfad, `Bitte gib ${name} als Betrag über 0 € und höchstens 100.000 € an.`) + return null + } + return n + } + + const bestandFeld = (pfad, wert) => { + if (leer(wert)) return null + const n = ganzeZahl(wert) + if (n === null || n < 0 || n > MAX_BESTAND) { + fehl(pfad, 'Bitte gib den Bestand als ganze Zahl ab 0 an oder lass das Feld leer für unbegrenzt.') + return null + } + return n + } + + const name = text('name', hole(eingabe, 'name'), { max: 120, min: 1, name: 'einen Namen für den Artikel' }) + const kategorie = text('kategorie', hole(eingabe, 'kategorie'), { max: 40, name: 'die Kategorie' }) + const kurz = text('kurz', hole(eingabe, 'kurz'), { max: 300, name: 'die Kurzbeschreibung' }) + const beschreibung = text('beschreibung', hole(eingabe, 'beschreibung'), { max: 5000, mehrzeilig: true, name: 'die Beschreibung' }) + + const aktivRoh = hole(eingabe, 'aktiv') + let aktiv = true + if (aktivRoh !== undefined && aktivRoh !== null) { + if (typeof aktivRoh !== 'boolean') fehl('aktiv', 'Bitte wähle bei „Aktiv“ ja oder nein.') + else aktiv = aktivRoh + } + + // Steuer + let steuersatz + const steuerRoh = hole(eingabe, 'steuersatz') + if (kleinunternehmer) steuersatz = 0 + else if (leer(steuerRoh)) steuersatz = 19 + else { + const n = ganzeZahl(steuerRoh) + if (n === null || !STEUERSAETZE.includes(n)) { + fehl('steuersatz', 'Bitte wähle als Steuersatz 0 %, 7 % oder 19 %.') + steuersatz = 19 + } else steuersatz = n + } + + // Varianten (ohne Namen und ohne Werte = keine Varianten) + let varianten = null + const variantenRoh = hole(eingabe, 'varianten') + if (variantenRoh !== undefined && variantenRoh !== null) { + if (!istObjekt(variantenRoh)) fehl('varianten', 'Die Varianten sind unlesbar.') + else { + const wertRoh = hole(variantenRoh, 'werte') + const werteListe = Array.isArray(wertRoh) ? wertRoh : [] + const leerVariante = leer(hole(variantenRoh, 'name')) && werteListe.length === 0 + if (wertRoh !== undefined && wertRoh !== null && !Array.isArray(wertRoh)) fehl('varianten.werte', 'Die Werte der Variante sind unlesbar.') + else if (!leerVariante) { + const vName = text('varianten.name', hole(variantenRoh, 'name'), { max: 30, min: 1, name: 'einen Namen für die Variante (zum Beispiel „Größe“)' }) + const max = config.shop.maxVarianten + if (werteListe.length === 0) fehl('varianten.werte', 'Bitte lege mindestens einen Wert für die Variante an.') + else if (werteListe.length > max) fehl('varianten.werte', `Du kannst höchstens ${max} Werte je Variante anlegen.`) + const benutzt = new Set() + const namen = new Set() + const werte = [] + werteListe.slice(0, max).forEach((w, i) => { + const pf = `varianten.werte.${i}` + if (!istObjekt(w)) { + fehl(pf, 'Dieser Wert ist unlesbar.') + return + } + let id = hole(w, 'id') + if (typeof id !== 'string' || !WERT_ID_RE.test(id) || benutzt.has(id)) { + do id = neueWertId() + while (benutzt.has(id)) + } + benutzt.add(id) + const wName = text(`${pf}.name`, hole(w, 'name'), { max: 40, min: 1, name: 'einen Namen für den Wert' }) + if (wName && namen.has(wName.toLowerCase())) fehl(`${pf}.name`, `Den Wert „${wName}“ gibt es schon, bitte wähle einen anderen Namen.`) + namen.add(wName.toLowerCase()) + werte.push({ + id, + name: wName, + preisCent: cent(`${pf}.preisCent`, hole(w, 'preisCent'), 'den Preis des Werts'), + bestand: bestandFeld(`${pf}.bestand`, hole(w, 'bestand')), + }) + }) + varianten = { name: vName, werte } + } + } + } + + // Preis und Bestand: mit Varianten zaehlen nur die Werte + let preisCent = null + let bestand = null + if (!varianten) { + preisCent = cent('preisCent', hole(eingabe, 'preisCent'), 'den Preis') + bestand = bestandFeld('bestand', hole(eingabe, 'bestand')) + } + + // Grundpreis + let grundpreis = null + const gpRoh = hole(eingabe, 'grundpreis') + if (gpRoh !== undefined && gpRoh !== null) { + if (!istObjekt(gpRoh)) fehl('grundpreis', 'Der Grundpreis ist unlesbar.') + else if (!(leer(hole(gpRoh, 'menge')) && leer(hole(gpRoh, 'einheit')))) { + let mengeRoh = hole(gpRoh, 'menge') + if (typeof mengeRoh === 'string') mengeRoh = /^\d+([.,]\d+)?$/.test(mengeRoh.trim()) ? Number(mengeRoh.trim().replace(',', '.')) : NaN + const einheit = hole(gpRoh, 'einheit') + const mengeOk = typeof mengeRoh === 'number' && Number.isFinite(mengeRoh) && mengeRoh > 0 && mengeRoh <= 1_000_000 + const einheitOk = typeof einheit === 'string' && EINHEITEN.includes(einheit) + if (!mengeOk) fehl('grundpreis.menge', 'Bitte gib die Menge für den Grundpreis als Zahl über 0 an.') + if (!einheitOk) fehl('grundpreis.einheit', 'Bitte wähle als Einheit für den Grundpreis g, kg, ml, l, m oder m².') + if (mengeOk && einheitOk) { + if (grundpreisText({ preisCent: 100, menge: mengeRoh, einheit }) === '') fehl('grundpreis.menge', 'Diese Menge ist für den Grundpreis zu klein oder zu groß.') + else grundpreis = { menge: mengeRoh, einheit } + } + } + } + + // Bilder + const bilder = [] + const bilderRoh = hole(eingabe, 'bilder') + if (bilderRoh !== undefined && bilderRoh !== null) { + if (!Array.isArray(bilderRoh)) fehl('bilder', 'Die Bilder sind unlesbar.') + else if (bilderRoh.length > config.shop.maxBilder) fehl('bilder', `Du kannst höchstens ${config.shop.maxBilder} Bilder je Artikel anlegen.`) + else { + for (const id of bilderRoh) { + if (typeof id !== 'string' || !BILD_ID_RE.test(id)) { + fehl('bilder', 'Eines der Bilder ist ungültig, bitte lade es neu hoch.') + break + } + if (bilder.includes(id)) { + fehl('bilder', 'Du hast dasselbe Bild mehrfach hinzugefügt.') + break + } + bilder.push(id) + } + } + } + + // Lieferart und Widerruf + let lieferart = 'beides' + const lieferRoh = hole(eingabe, 'lieferart') + if (!leer(lieferRoh)) { + if (typeof lieferRoh === 'string' && LIEFERARTEN.includes(lieferRoh)) lieferart = lieferRoh + else fehl('lieferart', 'Bitte wähle als Lieferart „Versand und Abholung“ oder „Nur Abholung“.') + } + let widerruf = 'gesetzlich' + const widerrufRoh = hole(eingabe, 'widerruf') + if (!leer(widerrufRoh)) { + if (typeof widerrufRoh === 'string' && WIDERRUFE.includes(widerrufRoh)) widerruf = widerrufRoh + else fehl('widerruf', 'Bitte wähle eine der angebotenen Möglichkeiten beim Widerrufsrecht.') + } + + // Produktsicherheit (GPSR) + const siRoh = hole(eingabe, 'sicherheit') + const adresse = (pfad, wert, { pflicht, bezeichnung }) => { + if (!istObjekt(wert)) { + if (pflicht) fehl(pfad, `Bitte gib ${bezeichnung} mit Name, Anschrift und Kontakt an.`) + return null + } + const felder = { name: 'den Namen', anschrift: 'die Anschrift', kontakt: 'einen Kontakt (E-Mail oder Telefon)' } + const roh = Object.fromEntries(Object.keys(felder).map((k) => [k, hole(wert, k)])) + if (!pflicht && Object.values(roh).every(leer)) return null + const r = {} + for (const [k, was] of Object.entries(felder)) r[k] = text(`${pfad}.${k}`, roh[k], { max: k === 'anschrift' ? 300 : 120, min: 1, name: `${was} bei ${bezeichnung}` }) + return r + } + // Fehlt der Hersteller ganz, gilt „selbst“ (Standard des Formulars); ein Objekt braucht Name, Anschrift und Kontakt + let hersteller = 'selbst' + const hRoh = hole(siRoh, 'hersteller') + if (hRoh !== undefined && hRoh !== null && hRoh !== 'selbst') { + if (istObjekt(hRoh)) hersteller = adresse('sicherheit.hersteller', hRoh, { pflicht: true, bezeichnung: 'den Hersteller' }) + else fehl('sicherheit.hersteller', 'Bitte gib den Hersteller mit Name, Anschrift und Kontakt an oder wähle „Ich bin der Hersteller“.') + } + const euVerantwortlicher = adresse('sicherheit.euVerantwortlicher', hole(siRoh, 'euVerantwortlicher'), { pflicht: false, bezeichnung: 'den EU-Verantwortlichen' }) + const hinweise = text('sicherheit.hinweise', hole(siRoh, 'hinweise'), { max: 2000, mehrzeilig: true, name: 'die Sicherheitshinweise' }) + + // Lebensmittel + const lmRoh = hole(eingabe, 'lebensmittel') + const lmAktivRoh = hole(lmRoh, 'aktiv') + if (lmAktivRoh !== undefined && lmAktivRoh !== null && typeof lmAktivRoh !== 'boolean') fehl('lebensmittel.aktiv', 'Bitte wähle bei „Lebensmittel“ ja oder nein.') + const lmAktiv = lmAktivRoh === true + const pflichtangaben = text('lebensmittel.pflichtangaben', hole(lmRoh, 'pflichtangaben'), { max: 4000, mehrzeilig: true, min: lmAktiv ? 1 : 0, name: 'die Pflichtangaben für Lebensmittel (Zutaten, Allergene, Füllmenge)' }) + + if (Object.keys(fehler).length > 0) return { fehler } + return { + produkt: { + name, + kategorie, + kurz, + beschreibung, + preisCent, + steuersatz, + grundpreis, + bestand, + aktiv, + varianten, + bilder, + lieferart, + widerruf, + sicherheit: { hersteller, euVerantwortlicher, hinweise }, + lebensmittel: { aktiv: lmAktiv, pflichtangaben }, + }, + } +} + +/** Gespeicherten JSON-Text sicher lesen; null bei kaputten Daten. */ +function parseProdukt(doc) { + try { + const p = JSON.parse(doc?.produkt) + return istObjekt(p) ? p : null + } catch { + return null + } +} + +/** Menge, die noch bestellbar ist: null = unbegrenzt. */ +function frei(bestand, reserviert, schluessel) { + if (!Number.isInteger(bestand)) return null + const r = reserviert instanceof Map ? Number(reserviert.get(schluessel)) || 0 : 0 + return Math.max(0, bestand - r) +} + +/** + * Sicht des Kaeufers auf einen Artikel (Anzeigefelder, fertige Texte). Liefert null bei unlesbarem Datensatz. + * Inaktive Artikel filtert der Aufrufer (doc.aktiv / produkt.aktiv). + * reserviert: Map offener Bestellungen. + */ +export function produktOeffentlich(produktDoc, { einstellungen, reserviert = new Map(), siteKey } = {}) { + void einstellungen + const p = parseProdukt(produktDoc) + if (!p) return null + const id = produktDoc.$id + const gp = p.grundpreis && typeof p.grundpreis === 'object' ? p.grundpreis : null + const grundpreisFuer = (preisCent) => (gp && Number.isInteger(preisCent) ? grundpreisText({ preisCent, menge: gp.menge, einheit: gp.einheit }) : '') + + let preisText = '' + let preisAbText + let gpText = '' + let variantenOeffentlich = null + let verfuegbar + + const v = p.varianten && typeof p.varianten === 'object' && Array.isArray(p.varianten.werte) && p.varianten.werte.length > 0 ? p.varianten : null + if (v) { + const werte = v.werte.map((w) => { + const rest = frei(w.bestand, reserviert, `${id}:${w.id}`) + return { + id: w.id, + name: w.name, + preisText: Number.isInteger(w.preisCent) ? euro(w.preisCent) : '', + grundpreisText: grundpreisFuer(w.preisCent), + verfuegbar: rest === null || rest > 0, + } + }) + const preise = v.werte.map((w) => w.preisCent).filter(Number.isInteger) + const min = preise.length ? Math.min(...preise) : null + const max = preise.length ? Math.max(...preise) : null + if (min !== null) { + preisText = euro(min) + if (min !== max) preisAbText = `ab ${euro(min)}` + gpText = min === max ? grundpreisFuer(min) : '' + } + variantenOeffentlich = { name: v.name, werte } + verfuegbar = werte.some((w) => w.verfuegbar) + } else { + if (Number.isInteger(p.preisCent)) { + preisText = euro(p.preisCent) + gpText = grundpreisFuer(p.preisCent) + } + const rest = frei(p.bestand, reserviert, id) + verfuegbar = rest === null || rest > 0 + } + + const si = istObjekt(p.sicherheit) ? p.sicherheit : {} + const adr = (a) => (istObjekt(a) ? { name: a.name ?? '', anschrift: a.anschrift ?? '', kontakt: a.kontakt ?? '' } : null) + const key = encodeURIComponent(String(siteKey ?? '')) + const bilder = (Array.isArray(p.bilder) ? p.bilder : []) + .filter((b) => typeof b === 'string' && BILD_ID_RE.test(b)) + .map((b) => ({ klein: `/api/public/shop/v1/${key}/bild/${b}-s.webp`, gross: `/api/public/shop/v1/${key}/bild/${b}-l.webp` })) + const lm = istObjekt(p.lebensmittel) && p.lebensmittel.aktiv === true ? { pflichtangaben: p.lebensmittel.pflichtangaben ?? '' } : null + + const ergebnis = { + id, + name: p.name ?? '', + kategorie: p.kategorie ?? '', + kurz: p.kurz ?? '', + beschreibung: p.beschreibung ?? '', + preisText, + grundpreisText: gpText, + steuersatz: p.steuersatz ?? 0, + varianten: variantenOeffentlich, + bilder, + lieferart: p.lieferart === 'abholung' ? 'abholung' : 'beides', + lieferHinweis: p.lieferart === 'abholung' ? 'Nur Abholung' : '', + widerruf: p.widerruf ?? 'gesetzlich', + widerrufText: WIDERRUF_TEXTE[p.widerruf] ?? '', + sicherheit: { + hersteller: si.hersteller === 'selbst' ? null : adr(si.hersteller), + euVerantwortlicher: adr(si.euVerantwortlicher), + hinweise: si.hinweise ?? '', + }, + lebensmittel: lm, + verfuegbar, + ausverkauft: !verfuegbar, + } + if (preisAbText) ergebnis.preisAbText = preisAbText + return ergebnis +} + +// --------------------------------------------------------------------------------------------------------------------- +// Speichern (shopProducts) + +const fehlerMit = (status, message) => Object.assign(new Error(message), { status }) +const sperre = (shopId) => `shop-produkte:${shopId}` +const col = () => config.collections.shopProducts +const reihenfolge = (a, b) => (a.sort ?? 0) - (b.sort ?? 0) || String(a.$createdAt ?? '').localeCompare(String(b.$createdAt ?? '')) || String(a.$id).localeCompare(String(b.$id)) + +/** Alle Artikel-Dokumente eines Shops, sortiert nach sort, dann Anlegezeit. */ +export async function listeProdukte(shopId) { + const docs = await listAllDocuments(col(), [Query.equal('shopId', shopId)]) + return docs.sort(reihenfolge) +} + +/** Ein Artikel-Dokument oder null (nicht vorhanden oder gehoert zu einem anderen Shop). */ +export async function holeProdukt(shopId, id) { + if (typeof id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id)) return null + let doc + try { + doc = await getDocument(col(), id) + } catch (error) { + if (error?.status === 404) return null + throw error + } + return doc && doc.shopId === shopId ? doc : null +} + +/** + * Legt einen Artikel an (id = null, ans Ende sortiert, hoechstens config.shop.maxArtikel) oder aktualisiert ihn (Sortierung bleibt). + * produkt ist das Ergebnis von normalisiereProdukt. Fehler tragen .status (400/404/409) und einen deutschen Text. + */ +export async function speichereProdukt({ shopId, customerId }, id, produkt) { + if (!istObjekt(produkt) || typeof produkt.name !== 'string' || produkt.name === '') throw fehlerMit(400, 'Der Artikel ist unvollständig.') + const json = JSON.stringify(produkt) + if (json.length > MAX_JSON) throw fehlerMit(400, 'Der Artikel ist zu umfangreich. Bitte kürze die Beschreibung oder lege weniger Varianten an.') + const daten = { name: produkt.name, aktiv: produkt.aktiv !== false, produkt: json } + return withLocks([sperre(shopId)], async () => { + if (id) { + const vorhanden = await holeProdukt(shopId, id) + if (!vorhanden) throw fehlerMit(404, 'Diesen Artikel gibt es nicht (mehr).') + return updateDocument(col(), id, daten) + } + const alle = await listeProdukte(shopId) + const max = config.shop.maxArtikel + if (alle.length >= max) throw fehlerMit(409, `Du hast die Höchstzahl von ${max} Artikeln erreicht. Lösche einen Artikel, bevor du einen neuen anlegst.`) + const sort = alle.reduce((m, d) => Math.max(m, Number.isInteger(d.sort) ? d.sort : -1), -1) + 1 + return createDocument(col(), { shopId, customerId, sort, ...daten }) + }) +} + +/** Loescht einen Artikel; 404, wenn er nicht (mehr) zu diesem Shop gehoert. */ +export async function loescheProdukt(shopId, id) { + return withLocks([sperre(shopId)], async () => { + const vorhanden = await holeProdukt(shopId, id) + if (!vorhanden) throw fehlerMit(404, 'Diesen Artikel gibt es nicht (mehr).') + await deleteDocument(col(), id) + return true + }) +} + +/** Setzt die Reihenfolge: ids muessen genau die Artikel des Shops enthalten (jeder einmal), sonst 400. Schreibt sort 0..n-1. */ +export async function sortiere(shopId, ids) { + if (!Array.isArray(ids) || ids.some((i) => typeof i !== 'string')) throw fehlerMit(400, 'Die Reihenfolge ist ungültig.') + return withLocks([sperre(shopId)], async () => { + const alle = await listeProdukte(shopId) + const vorhanden = new Set(alle.map((d) => d.$id)) + if (new Set(ids).size !== ids.length || ids.length !== vorhanden.size || ids.some((i) => !vorhanden.has(i))) { + throw fehlerMit(400, 'Die Reihenfolge passt nicht zu deinen Artikeln. Bitte lade die Seite neu und versuche es noch einmal.') + } + const nachId = new Map(alle.map((d) => [d.$id, d])) + const neu = [] + for (const [index, docId] of ids.entries()) { + const doc = nachId.get(docId) + neu.push(doc.sort === index ? doc : await updateDocument(col(), docId, { sort: index })) + } + return neu + }) +} + +/** Alle Bild-IDs, die Artikel des Shops verwenden (Set), damit Aufraeumjobs keine benutzten Bilder loeschen. */ +export async function bildIdsInGebrauch(shopId) { + const ids = new Set() + for (const doc of await listeProdukte(shopId)) { + const p = parseProdukt(doc) + if (Array.isArray(p?.bilder)) for (const b of p.bilder) if (typeof b === 'string') ids.add(b) + } + return ids +} diff --git a/test/shopProdukte.test.js b/test/shopProdukte.test.js new file mode 100644 index 0000000..9671070 --- /dev/null +++ b/test/shopProdukte.test.js @@ -0,0 +1,398 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Artikel des Shops: reines Normalisieren, Kaeufer-Sicht und Speichern gegen den Appwrite-Nachbau. +const aw = await startAppwriteMock({ schema: { shopProducts: schemaKeys('shopProducts') }, datetimes: SHOP_DATETIMES }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const P = await import('../server/services/shop/produkte.js') +const { normalisiereProdukt, produktOeffentlich, listeProdukte, holeProdukt, speichereProdukt, loescheProdukt, sortiere, bildIdsInGebrauch } = P + +const klein = { kleinunternehmer: true } +const regel = { kleinunternehmer: false } +const basis = (over = {}) => ({ name: 'Lavendelseife', preisCent: 450, ...over }) +const ok = (eingabe, einstellungen = regel) => { + const r = normalisiereProdukt(eingabe, { einstellungen }) + assert.equal(r.fehler, undefined, JSON.stringify(r.fehler)) + return r.produkt +} +const fehler = (eingabe, einstellungen = regel) => { + const r = normalisiereProdukt(eingabe, { einstellungen }) + assert.equal(r.produkt, undefined) + return r.fehler +} + +test('Minimaler Artikel bekommt die Standardwerte', () => { + const p = ok(basis()) + assert.deepEqual(p, { + name: 'Lavendelseife', + kategorie: '', + kurz: '', + beschreibung: '', + preisCent: 450, + steuersatz: 19, + grundpreis: null, + bestand: null, + aktiv: true, + varianten: null, + bilder: [], + lieferart: 'beides', + widerruf: 'gesetzlich', + sicherheit: { hersteller: 'selbst', euVerantwortlicher: null, hinweise: '' }, + lebensmittel: { aktiv: false, pflichtangaben: '' }, + }) +}) + +test('Name, Preis und Laengen werden geprueft', () => { + assert.ok(fehler({ preisCent: 100 }).name) + assert.ok(fehler(basis({ name: ' ' })).name) + assert.ok(fehler(basis({ name: 'x'.repeat(121) })).name) + assert.ok(fehler(basis({ kategorie: 'x'.repeat(41) })).kategorie) + assert.ok(fehler(basis({ kurz: 'x'.repeat(301) })).kurz) + assert.ok(fehler(basis({ beschreibung: 'x'.repeat(5001) })).beschreibung) + for (const preis of [0, -5, 10_000_001, 4.5, '4,50', null, undefined]) assert.ok(fehler(basis({ preisCent: preis })).preisCent, String(preis)) + assert.equal(ok(basis({ preisCent: 10_000_000 })).preisCent, 10_000_000) + assert.ok(fehler('kaputt').produkt) + assert.ok(fehler(null).produkt) + assert.ok(fehler([]).produkt) +}) + +test('Texte: getrimmt, Steuerzeichen entfernt, Umbrueche nur in Langtexten', () => { + const p = ok(basis({ name: ' Seife\u0000​ mit\nUmbruch ', kurz: 'a\nb', beschreibung: 'Zeile 1\r\nZeile\u0007 2', sicherheit: { hinweise: 'Nicht\nessen\u0000' } })) + assert.equal(p.name, 'Seife mit Umbruch') + assert.equal(p.kurz, 'a b') + assert.equal(p.beschreibung, 'Zeile 1\nZeile 2') + assert.equal(p.sicherheit.hinweise, 'Nicht\nessen') +}) + +test('Steuersatz: 0, 7, 19; Kleinunternehmer erzwingt 0', () => { + assert.equal(ok(basis({ steuersatz: 7 })).steuersatz, 7) + assert.equal(ok(basis({ steuersatz: 0 })).steuersatz, 0) + assert.equal(ok(basis({ steuersatz: 19 })).steuersatz, 19) + assert.ok(fehler(basis({ steuersatz: 16 })).steuersatz) + assert.ok(fehler(basis({ steuersatz: 'viel' })).steuersatz) + assert.equal(ok(basis({ steuersatz: 19 }), klein).steuersatz, 0) + assert.equal(ok(basis({ steuersatz: 7 }), klein).steuersatz, 0) + assert.equal(ok(basis(), klein).steuersatz, 0) +}) + +test('Bestand: ganze Zahl ab 0 oder unbegrenzt', () => { + assert.equal(ok(basis({ bestand: 0 })).bestand, 0) + assert.equal(ok(basis({ bestand: 12 })).bestand, 12) + assert.equal(ok(basis({ bestand: '7' })).bestand, 7) + assert.equal(ok(basis({ bestand: null })).bestand, null) + assert.equal(ok(basis({ bestand: '' })).bestand, null) + for (const b of [-1, 1.5, 'viele']) assert.ok(fehler(basis({ bestand: b })).bestand, String(b)) +}) + +test('Grundpreis: Menge und Einheit', () => { + assert.deepEqual(ok(basis({ grundpreis: { menge: 100, einheit: 'g' } })).grundpreis, { menge: 100, einheit: 'g' }) + assert.deepEqual(ok(basis({ grundpreis: { menge: '0,75', einheit: 'l' } })).grundpreis, { menge: 0.75, einheit: 'l' }) + assert.equal(ok(basis({ grundpreis: null })).grundpreis, null) + assert.equal(ok(basis({ grundpreis: { menge: '', einheit: '' } })).grundpreis, null) + assert.ok(fehler(basis({ grundpreis: { menge: 0, einheit: 'g' } })).grundpreis?.menge ?? fehler(basis({ grundpreis: { menge: 0, einheit: 'g' } }))['grundpreis.menge']) + assert.ok(fehler(basis({ grundpreis: { menge: 100, einheit: 'stueck' } }))['grundpreis.einheit']) + assert.ok(fehler(basis({ grundpreis: { menge: 'viel', einheit: 'g' } }))['grundpreis.menge']) + assert.ok(fehler(basis({ grundpreis: 'x' })).grundpreis) +}) + +test('aktiv, Lieferart, Widerruf', () => { + assert.equal(ok(basis({ aktiv: false })).aktiv, false) + assert.ok(fehler(basis({ aktiv: 'ja' })).aktiv) + assert.equal(ok(basis({ lieferart: 'abholung' })).lieferart, 'abholung') + assert.ok(fehler(basis({ lieferart: 'drohne' })).lieferart) + for (const w of ['gesetzlich', 'verderblich', 'individuell', 'hygiene']) assert.equal(ok(basis({ widerruf: w })).widerruf, w) + assert.ok(fehler(basis({ widerruf: 'nie' })).widerruf) +}) + +test('Varianten: Preis und Bestand nur an den Werten, IDs vom Server', () => { + const p = ok( + basis({ + preisCent: 999, + bestand: 5, + varianten: { + name: 'Duft', + werte: [ + { name: 'Lavendel', preisCent: 350, bestand: 3 }, + { id: 'abcd1234', name: 'Rose', preisCent: 450 }, + { id: 'ZU-KURZ', name: 'Minze', preisCent: 400, bestand: '0' }, + { id: 'abcd1234', name: 'Zitrone', preisCent: 400 }, + ], + }, + }), + ) + assert.equal(p.preisCent, null) + assert.equal(p.bestand, null) + assert.equal(p.varianten.name, 'Duft') + const ids = p.varianten.werte.map((w) => w.id) + assert.equal(ids[1], 'abcd1234') + for (const id of ids) assert.match(id, /^[a-z0-9]{8}$/) + assert.equal(new Set(ids).size, 4) + assert.deepEqual(p.varianten.werte.map((w) => w.bestand), [3, null, 0, null]) + assert.deepEqual(p.varianten.werte.map((w) => w.preisCent), [350, 450, 400, 400]) +}) + +test('Varianten: Fehler je Wert', () => { + const f = fehler(basis({ varianten: { name: '', werte: [{ name: 'A', preisCent: 0 }, { name: '', preisCent: 100 }, { name: 'a', preisCent: 100 }] } })) + assert.ok(f['varianten.name']) + assert.ok(f['varianten.werte.0.preisCent']) + assert.ok(f['varianten.werte.1.name']) + assert.ok(f['varianten.werte.2.name']) + assert.ok(fehler(basis({ varianten: { name: 'Größe', werte: [] } }))['varianten.werte']) + const zuViele = Array.from({ length: config.shop.maxVarianten + 1 }, (_, i) => ({ name: `W${i}`, preisCent: 100 })) + assert.ok(fehler(basis({ varianten: { name: 'Größe', werte: zuViele } }))['varianten.werte']) + const genau = zuViele.slice(0, config.shop.maxVarianten) + assert.equal(ok(basis({ varianten: { name: 'Größe', werte: genau } })).varianten.werte.length, config.shop.maxVarianten) + // leere Variante = keine Varianten, dann zaehlt der Artikelpreis + assert.equal(ok(basis({ varianten: { name: '', werte: [] } })).varianten, null) + assert.equal(ok(basis({ varianten: null })).varianten, null) +}) + +test('Bilder: Bild-IDs, hoechstens maxBilder, eindeutig', () => { + assert.deepEqual(ok(basis({ bilder: ['abc-123', 'def'] })).bilder, ['abc-123', 'def']) + assert.ok(fehler(basis({ bilder: ['a', 'b', 'c', 'd', 'e'] })).bilder) + assert.equal(ok(basis({ bilder: ['a', 'b', 'c', 'd'] })).bilder.length, config.shop.maxBilder) + assert.ok(fehler(basis({ bilder: ['a', 'a'] })).bilder) + assert.ok(fehler(basis({ bilder: ['../x'] })).bilder) + assert.ok(fehler(basis({ bilder: ['A'] })).bilder) + assert.ok(fehler(basis({ bilder: [''] })).bilder) + assert.ok(fehler(basis({ bilder: [1] })).bilder) + assert.ok(fehler(basis({ bilder: 'abc' })).bilder) +}) + +test('Lebensmittel braucht Pflichtangaben', () => { + const f = fehler(basis({ lebensmittel: { aktiv: true, pflichtangaben: ' ' } })) + assert.ok(f['lebensmittel.pflichtangaben']) + assert.deepEqual(ok(basis({ lebensmittel: { aktiv: true, pflichtangaben: 'Zutaten: Mehl. Allergene: Gluten.' } })).lebensmittel, { aktiv: true, pflichtangaben: 'Zutaten: Mehl. Allergene: Gluten.' }) + assert.ok(fehler(basis({ lebensmittel: { aktiv: true, pflichtangaben: 'x'.repeat(4001) } }))['lebensmittel.pflichtangaben']) + assert.equal(ok(basis({ lebensmittel: { aktiv: false } })).lebensmittel.aktiv, false) +}) + +test('Produktsicherheit: Hersteller selbst oder vollstaendig, EU-Verantwortlicher optional aber vollstaendig', () => { + const herst = { name: 'Seifen GmbH', anschrift: 'Weg 1, 12345 Ort', kontakt: 'info@seifen.example' } + assert.equal(ok(basis({ sicherheit: { hersteller: 'selbst' } })).sicherheit.hersteller, 'selbst') + assert.deepEqual(ok(basis({ sicherheit: { hersteller: herst } })).sicherheit.hersteller, herst) + const f = fehler(basis({ sicherheit: { hersteller: { name: 'X' } } })) + assert.ok(f['sicherheit.hersteller.anschrift']) + assert.ok(f['sicherheit.hersteller.kontakt']) + assert.ok(fehler(basis({ sicherheit: { hersteller: 'jemand' } }))['sicherheit.hersteller']) + assert.ok(fehler(basis({ sicherheit: { hersteller: 42 } }))['sicherheit.hersteller']) + assert.deepEqual(ok(basis({ sicherheit: { hersteller: herst, euVerantwortlicher: herst } })).sicherheit.euVerantwortlicher, herst) + assert.equal(ok(basis({ sicherheit: { hersteller: herst, euVerantwortlicher: { name: '', anschrift: '', kontakt: '' } } })).sicherheit.euVerantwortlicher, null) + assert.ok(fehler(basis({ sicherheit: { hersteller: herst, euVerantwortlicher: { name: 'EU' } } }))['sicherheit.euVerantwortlicher.anschrift']) + assert.ok(fehler(basis({ sicherheit: { hinweise: 'x'.repeat(2001) } }))['sicherheit.hinweise']) +}) + +test('Normalisieren teilt keine Referenzen und ignoriert unbekannte Felder und __proto__', () => { + const eingabe = JSON.parse('{"name":"A","preisCent":100,"bilder":["a"],"__proto__":{"preisCent":1},"boese":1}') + const p = ok(eingabe) + p.bilder.push('x') + assert.deepEqual(eingabe.bilder, ['a']) + assert.equal(p.boese, undefined) + assert.equal(p.preisCent, 100) +}) + +// ---- Kaeufer-Sicht ------------------------------------------------------------------------------------------------- + +const doc = (produkt, id = 'p1') => ({ $id: id, produkt: JSON.stringify(produkt) }) +const oeff = (eingabe, extra = {}, einstellungen = regel) => { + const d = doc(ok(eingabe, einstellungen), extra.id) + return produktOeffentlich(d, { einstellungen, siteKey: 'abc123', ...extra }) +} + +test('produktOeffentlich: Einzelartikel', () => { + const o = oeff(basis({ kurz: 'Handgemacht', grundpreis: { menge: 100, einheit: 'g' }, bilder: ['bild-1', 'bild2'], steuersatz: 7, bestand: 3, kategorie: 'Seife' })) + assert.equal(o.id, 'p1') + assert.equal(o.name, 'Lavendelseife') + assert.equal(o.kategorie, 'Seife') + assert.equal(o.kurz, 'Handgemacht') + assert.equal(o.preisText, '4,50\u00a0€') + assert.equal(o.preisAbText, undefined) + assert.equal(o.grundpreisText, '(45,00\u00a0€ / 1 kg)') + assert.equal(o.steuersatz, 7) + assert.equal(o.varianten, null) + assert.deepEqual(o.bilder, [ + { klein: '/api/public/shop/v1/abc123/bild/bild-1-s.webp', gross: '/api/public/shop/v1/abc123/bild/bild-1-l.webp' }, + { klein: '/api/public/shop/v1/abc123/bild/bild2-s.webp', gross: '/api/public/shop/v1/abc123/bild/bild2-l.webp' }, + ]) + assert.equal(o.lieferart, 'beides') + assert.equal(o.lieferHinweis, '') + assert.equal(o.widerruf, 'gesetzlich') + assert.equal(o.widerrufText, '') + assert.deepEqual(o.sicherheit, { hersteller: null, euVerantwortlicher: null, hinweise: '' }) + assert.equal(o.lebensmittel, null) + assert.equal(o.verfuegbar, true) + assert.equal(o.ausverkauft, false) + assert.equal('bestand' in o, false) + assert.equal('bestandText' in o, false) + assert.equal('preisCent' in o, false) +}) + +test('produktOeffentlich: Lieferart, Widerrufstexte, Hersteller, Lebensmittel', () => { + assert.equal(oeff(basis({ lieferart: 'abholung' })).lieferHinweis, 'Nur Abholung') + assert.equal(oeff(basis({ widerruf: 'verderblich' })).widerrufText, 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware') + assert.match(oeff(basis({ widerruf: 'individuell' })).widerrufText, /nach Kundenwunsch angefertigt/) + assert.match(oeff(basis({ widerruf: 'hygiene' })).widerrufText, /versiegelte Ware aus Hygienegründen, wenn die Versiegelung entfernt wurde/) + const herst = { name: 'Seifen GmbH', anschrift: 'Weg 1', kontakt: 'a@b.de' } + const o = oeff(basis({ sicherheit: { hersteller: herst, hinweise: 'Nicht essen' }, lebensmittel: { aktiv: true, pflichtangaben: 'Zutaten: Salz' } })) + assert.deepEqual(o.sicherheit.hersteller, herst) + assert.equal(o.sicherheit.hinweise, 'Nicht essen') + assert.deepEqual(o.lebensmittel, { pflichtangaben: 'Zutaten: Salz' }) +}) + +test('produktOeffentlich: Verfuegbarkeit mit Bestand und Reservierung', () => { + const eingabe = basis({ bestand: 2 }) + assert.equal(oeff(eingabe).verfuegbar, true) + assert.equal(oeff(eingabe, { reserviert: new Map([['p1', 1]]) }).verfuegbar, true) + const aus = oeff(eingabe, { reserviert: new Map([['p1', 2]]) }) + assert.equal(aus.verfuegbar, false) + assert.equal(aus.ausverkauft, true) + assert.equal(oeff(basis({ bestand: 0 })).ausverkauft, true) + assert.equal(oeff(basis({ bestand: null }), { reserviert: new Map([['p1', 99]]) }).verfuegbar, true) + assert.equal(oeff(eingabe, { reserviert: new Map([['p1', 5]]) }).verfuegbar, false) + assert.equal(oeff(eingabe, { reserviert: undefined }).verfuegbar, true) +}) + +test('produktOeffentlich: Varianten mit "ab"-Preis und Verfuegbarkeit je Wert', () => { + const eingabe = basis({ + varianten: { + name: 'Duft', + werte: [ + { id: 'aaaaaaaa', name: 'Lavendel', preisCent: 350, bestand: 1 }, + { id: 'bbbbbbbb', name: 'Rose', preisCent: 450 }, + ], + }, + }) + const o = oeff(eingabe, { reserviert: new Map([['p1:aaaaaaaa', 1], ['p1', 50]]) }) + assert.equal(o.preisText, '3,50\u00a0€') + assert.equal(o.preisAbText, 'ab 3,50\u00a0€') + assert.equal(o.varianten.name, 'Duft') + assert.deepEqual(o.varianten.werte.map((w) => [w.id, w.name, w.preisText, w.verfuegbar]), [ + ['aaaaaaaa', 'Lavendel', '3,50\u00a0€', false], + ['bbbbbbbb', 'Rose', '4,50\u00a0€', true], + ]) + assert.equal(o.verfuegbar, true) + const alle = oeff(eingabe, { reserviert: new Map([['p1:aaaaaaaa', 1]]) }) + assert.equal(alle.verfuegbar, true) + const keine = oeff(basis({ varianten: { name: 'Duft', werte: [{ id: 'aaaaaaaa', name: 'L', preisCent: 350, bestand: 0 }] } })) + assert.equal(keine.ausverkauft, true) + // gleiche Preise: kein "ab" + const gleich = oeff(basis({ varianten: { name: 'Farbe', werte: [{ name: 'Rot', preisCent: 500 }, { name: 'Blau', preisCent: 500 }] }, grundpreis: { menge: 50, einheit: 'g' } })) + assert.equal(gleich.preisText, '5,00\u00a0€') + assert.equal(gleich.preisAbText, undefined) + assert.equal(gleich.grundpreisText, '(100,00\u00a0€ / 1 kg)') +}) + +test('produktOeffentlich: kaputter Datensatz liefert null, Fremdtext bleibt Text', () => { + assert.equal(produktOeffentlich({ $id: 'x', produkt: '{kaputt' }, { siteKey: 's' }), null) + assert.equal(produktOeffentlich({ $id: 'x', produkt: '"text"' }, { siteKey: 's' }), null) + assert.equal(produktOeffentlich({ $id: 'x' }, { siteKey: 's' }), null) + const o = oeff(basis({ name: '' })) + assert.equal(o.name, '') +}) + +// ---- Speichern ----------------------------------------------------------------------------------------------------- + +const SHOP = { shopId: 'shop-a', customerId: 'cust-a' } +const fremd = { shopId: 'shop-b', customerId: 'cust-b' } + +test('speichern: anlegen sortiert ans Ende, Update behaelt sort, JSON in produkt', async () => { + const a = await speichereProdukt(SHOP, null, ok(basis({ name: 'A' }))) + const b = await speichereProdukt(SHOP, null, ok(basis({ name: 'B' }))) + const c = await speichereProdukt(SHOP, null, ok(basis({ name: 'C', aktiv: false }))) + assert.deepEqual([a.sort, b.sort, c.sort], [0, 1, 2]) + assert.equal(a.shopId, 'shop-a') + assert.equal(a.customerId, 'cust-a') + assert.equal(c.aktiv, false) + assert.equal(JSON.parse(a.produkt).name, 'A') + const neu = await speichereProdukt(SHOP, b.$id, ok(basis({ name: 'B2', preisCent: 999 }))) + assert.equal(neu.sort, 1) + assert.equal(neu.name, 'B2') + assert.equal(JSON.parse(neu.produkt).preisCent, 999) + const liste = await listeProdukte('shop-a') + assert.deepEqual(liste.map((d) => d.name), ['A', 'B2', 'C']) +}) + +test('Shops sind getrennt: Liste, Holen, Aendern, Loeschen, Sortieren', async () => { + const x = await speichereProdukt(fremd, null, ok(basis({ name: 'Fremd' }))) + assert.equal(x.sort, 0) + assert.deepEqual((await listeProdukte('shop-b')).map((d) => d.name), ['Fremd']) + assert.equal(await holeProdukt('shop-a', x.$id), null) + assert.equal((await holeProdukt('shop-b', x.$id)).name, 'Fremd') + assert.equal(await holeProdukt('shop-a', 'gibt-es-nicht'), null) + assert.equal(await holeProdukt('shop-a', '../etc'), null) + await assert.rejects(speichereProdukt(SHOP, x.$id, ok(basis())), (e) => e.status === 404) + await assert.rejects(loescheProdukt('shop-a', x.$id), (e) => e.status === 404) + await assert.rejects(sortiere('shop-a', [x.$id]), (e) => e.status === 400) + assert.equal((await holeProdukt('shop-b', x.$id)).name, 'Fremd') +}) + +test('speichern: unvollstaendiger oder zu grosser Artikel => 400', async () => { + await assert.rejects(speichereProdukt(SHOP, null, {}), (e) => e.status === 400 && /unvollständig/.test(e.message)) + await assert.rejects(speichereProdukt(SHOP, null, null), (e) => e.status === 400) + const riesig = ok(basis({ sicherheit: { hinweise: 'ä'.repeat(2000) }, beschreibung: 'ä'.repeat(5000), lebensmittel: { aktiv: true, pflichtangaben: 'ä'.repeat(4000) }, varianten: { name: 'V', werte: Array.from({ length: 30 }, (_, i) => ({ name: `Wert-${i}-${'x'.repeat(30)}`, preisCent: 100 })) } })) + riesig.kurz = 'ü'.repeat(20000) + await assert.rejects(speichereProdukt(SHOP, null, riesig), (e) => e.status === 400) +}) + +test('sortieren: genau die Artikel des Shops, sonst 400', async () => { + const liste = await listeProdukte('shop-a') + const ids = liste.map((d) => d.$id) + const neu = [ids[2], ids[0], ids[1]] + const r = await sortiere('shop-a', neu) + assert.deepEqual(r.map((d) => [d.$id, d.sort]), neu.map((id, i) => [id, i])) + assert.deepEqual((await listeProdukte('shop-a')).map((d) => d.$id), neu) + await assert.rejects(sortiere('shop-a', ids.slice(0, 2)), (e) => e.status === 400) + await assert.rejects(sortiere('shop-a', [...ids, 'noch-einer']), (e) => e.status === 400) + await assert.rejects(sortiere('shop-a', [ids[0], ids[0], ids[1]]), (e) => e.status === 400) + await assert.rejects(sortiere('shop-a', 'abc'), (e) => e.status === 400) + await assert.rejects(sortiere('shop-a', [1, 2, 3]), (e) => e.status === 400) + assert.deepEqual((await listeProdukte('shop-a')).map((d) => d.$id), neu) +}) + +test('loeschen und neu anlegen: sort = max + 1', async () => { + const liste = await listeProdukte('shop-a') + assert.equal(await loescheProdukt('shop-a', liste[0].$id), true) + assert.equal(await holeProdukt('shop-a', liste[0].$id), null) + await assert.rejects(loescheProdukt('shop-a', liste[0].$id), (e) => e.status === 404) + const neu = await speichereProdukt(SHOP, null, ok(basis({ name: 'Spaeter' }))) + assert.equal(neu.sort, 3) +}) + +test('Bild-IDs in Gebrauch', async () => { + await speichereProdukt(SHOP, null, ok(basis({ name: 'Mit Bildern', bilder: ['bild-x', 'bild-y'] }))) + await speichereProdukt(fremd, null, ok(basis({ name: 'Fremd2', bilder: ['bild-fremd'] }))) + const ids = await bildIdsInGebrauch('shop-a') + assert.ok(ids instanceof Set) + assert.deepEqual([...ids].sort(), ['bild-x', 'bild-y']) + assert.equal((await bildIdsInGebrauch('shop-leer')).size, 0) +}) + +test('Obergrenze maxArtikel => 409, Update bleibt moeglich', async () => { + const alt = config.shop.maxArtikel + const vorhanden = (await listeProdukte('shop-a')).length + config.shop.maxArtikel = vorhanden + try { + await assert.rejects(speichereProdukt(SHOP, null, ok(basis({ name: 'Zu viel' }))), (e) => e.status === 409 && /Höchstzahl/.test(e.message)) + const erster = (await listeProdukte('shop-a'))[0] + await speichereProdukt(SHOP, erster.$id, ok(basis({ name: 'Noch erlaubt' }))) + // anderer Shop hat eigenes Limit + config.shop.maxArtikel = (await listeProdukte('shop-b')).length + 1 + await speichereProdukt(fremd, null, ok(basis({ name: 'Passt gerade' }))) + await assert.rejects(speichereProdukt(fremd, null, ok(basis({ name: 'Zu viel' }))), (e) => e.status === 409) + } finally { + config.shop.maxArtikel = alt + } +}) + +test('parallele Anlage vergibt verschiedene sort-Werte', async () => { + const shop = { shopId: 'shop-par', customerId: 'c' } + const docs = await Promise.all(Array.from({ length: 5 }, (_, i) => speichereProdukt(shop, null, ok(basis({ name: `P${i}` }))))) + assert.deepEqual(docs.map((d) => d.sort).sort(), [0, 1, 2, 3, 4]) +})