Shop C7: Kundenrouten der Verwaltung (Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder)
- routes/shop.js: gemeinsame Pruefung mitShop (Schalter, eigenes Projekt, Funktion setup|active, lesend auch disabled, shopConfig bei Bedarf anlegen); GET /status erweitert (Bereitschaft, geoeffnet, Snippet, Vorschau, Stripe ohne Konto-ID, Zahlen); Einstellungen, Artikel (mit Bild-Pruefung, Beanspruchen und Aufraeumen), Sortierung, Rechtstexte (Entwurf, Bestaetigen mit Hash), Oeffnen/Schliessen, Bild-Upload (octet-stream, 8 MB) und Vorschau; Schreib-Limit 300 je Stunde und Kunde - services/shop/bilder.js: Artikelbilder nach dem Muster von newsImages.js (1600/480 px WebP, Sidecar, 24-h-Aufraeumen, 40 Uploads/h) - Kundenansicht: sieben Verwaltungs-Pfade erlaubt und als shop_aenderung protokolliert; Anfrage, Oeffnen, Bestaetigen, Stripe bleiben gesperrt - Tests: shopRoutes, shopBilder; Erwartungen von kundenansichtGuard, customerViewLog und shopAnfrage angepasst Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9d19a2cbad
commit
9722f0a938
10 files changed
+1389
-32
No files matched your search
@@ -24,6 +24,16 @@ export const KUNDENANSICHT_ALLOWED = [
|
||||
['POST', /^\/api\/termine\/buchungen\/[^/]+\/aktion\/?$/],
|
||||
['PATCH', /^\/api\/termine\/einstellungen\/?$/],
|
||||
['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/],
|
||||
// Webshop: Artikel, Einstellungen, Rechtstexte-Entwurf und Bilder darf das Team mit dem Kunden ausfuellen (protokolliert). Genau diese
|
||||
// sieben Pfade, jeder einzeln: Anfrage, Oeffnen, Bestaetigen der Rechtstexte (der Haendler bestaetigt persoenlich), Stripe und
|
||||
// Erstattungen bleiben gesperrt, ohne dass es einen Eintrag braucht.
|
||||
['POST', /^\/api\/shop\/produkte\/?$/],
|
||||
['POST', /^\/api\/shop\/produkte\/sortierung\/?$/],
|
||||
['PUT', /^\/api\/shop\/produkte\/[^/]+\/?$/],
|
||||
['DELETE', /^\/api\/shop\/produkte\/[^/]+\/?$/],
|
||||
['PUT', /^\/api\/shop\/einstellungen\/?$/],
|
||||
['PUT', /^\/api\/shop\/rechtstexte\/?$/],
|
||||
['POST', /^\/api\/shop\/bild\/?$/],
|
||||
['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/],
|
||||
['POST', /^\/api\/auth\/logout\/?$/],
|
||||
['POST', /^\/api\/auth\/login\/?$/],
|
||||
|
||||
+405
-26
@@ -1,30 +1,55 @@
|
||||
import { Router } from 'express'
|
||||
import express, { Router } from 'express'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { countDocuments, getDocument, listAllDocuments, Query, updateDocument } from '../services/appwriteAdmin.js'
|
||||
import { config } from '../config.js'
|
||||
import { isKundenansicht } from '../services/kundenansicht.js'
|
||||
import { withLocks } from '../services/keyedLock.js'
|
||||
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
|
||||
import { createWriteLimiter } from '../services/writeLimiter.js'
|
||||
import { anfrageAnsicht, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js'
|
||||
import { anfrageAnsicht, ensureShopConfig, leseShopConfig, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js'
|
||||
import { shopFuerKunde } from '../services/shop/schalter.js'
|
||||
import { uploadSlot } from './chat.js'
|
||||
import { istVollstaendig, speichereEinstellungen } from '../services/shop/einstellungen.js'
|
||||
import { bereitschaft, istGeoeffnet, ladeShop, leereCache } from '../services/shop/kontext.js'
|
||||
import {
|
||||
bildIdsInGebrauch, holeProdukt, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt,
|
||||
} from '../services/shop/produkte.js'
|
||||
import {
|
||||
BESTAETIGUNG_TEXT, GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, musterAgb, musterWiderrufsbelehrung, musterWiderrufsformular,
|
||||
normalisiereRechtstexte, rechtstexteHash, versandUndZahlung, vollstaendig,
|
||||
} from '../services/shop/rechtstexte.js'
|
||||
import {
|
||||
BILD_MAX_BYTES, beanspruche, bildLimitErreicht, bildMeta, bildPfad, loescheBild, setzeBildLimitZurueck, speichereBild,
|
||||
} from '../services/shop/bilder.js'
|
||||
|
||||
/**
|
||||
* Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Dieser Stand (Task B1) enthaelt
|
||||
* die Anfrage: GET /status, POST /anfrage, POST /anfrage/zurueckziehen. Weitere Routen kommen mit C7, D2 und E5.
|
||||
* Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Stand: Anfrage (B1) und
|
||||
* Verwaltung (C7): Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder. Stripe (D2) und Bestellungen (E) kommen dazu.
|
||||
*
|
||||
* Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Schreib-Routen sind in der Kundenansicht gesperrt
|
||||
* (kundenansichtGuard: nicht auf der Erlaubnisliste; hier zusaetzlich 403) und zaehlen gegen 20 Aufrufe je Kunde und Stunde.
|
||||
* Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Die Verwaltungs-Routen laufen ueber mitShop: Schalter
|
||||
* (409 nicht verfuegbar), Funktion im Zustand setup|active (lesend auch disabled), shopConfig vorhanden (setup|active legt es an).
|
||||
* Kundenansicht: Artikel, Einstellungen, Rechtstexte (nur Entwurf speichern) und Bilder darf das Team mit ausfuellen und werden
|
||||
* protokolliert (kundenansichtGuard); Anfrage, Oeffnen und Bestaetigen der Rechtstexte bleiben dem Kunden vorbehalten (hier 403).
|
||||
* Schreib-Routen zaehlen gegen 300 Aufrufe je Kunde und Stunde (Anfrage-Routen gegen 20), Bilder zusaetzlich gegen 40 je Stunde.
|
||||
* Fehler: { error: <Text>, fehler?: { <feld>: Text }, code? }; Texte von Appwrite oder unbekannten Fehlern verlassen den Server nie.
|
||||
*/
|
||||
|
||||
const router = Router()
|
||||
|
||||
const MAX_WRITES_PER_HOUR = 20
|
||||
const MAX_ANFRAGE_WRITES_PER_HOUR = 20
|
||||
const MAX_WRITES_PER_HOUR = 300
|
||||
const PRODUKT_DOC_MAX_JSON = 100000
|
||||
const BILD_DATEI_RE = /^([a-z0-9-]{1,64})-(l|s)\.webp$/
|
||||
const MELDUNG = {
|
||||
projekt: 'Bitte wähle ein Projekt.',
|
||||
fremd: 'Kein Zugriff auf dieses Projekt.',
|
||||
kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.',
|
||||
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
|
||||
nichtVerfuegbar: 'Der Webshop ist noch nicht verfügbar.',
|
||||
nichtEingerichtet: 'Der Webshop ist für dieses Projekt noch nicht eingerichtet.',
|
||||
zuVieleBilder: 'Zu viele Bilder in kurzer Zeit. Bitte versuch es später wieder.',
|
||||
bildFehlt: 'Bild nicht gefunden.',
|
||||
eingabe: 'Die Eingabe fehlt oder ist unlesbar.',
|
||||
}
|
||||
|
||||
/** Fehler des Kunden (4xx/503 ohne Appwrite-Typ) wie sie sind, alles andere 500 ohne Rohtext. */
|
||||
@@ -35,7 +60,12 @@ function fail(res, err) {
|
||||
return res.status(500).json({ error: 'Interner Serverfehler' })
|
||||
}
|
||||
const body = { ...err.body, error: err.message }
|
||||
if (err.fehler && typeof err.fehler === 'object') body.fehler = err.fehler
|
||||
if (err.fehler && typeof err.fehler === 'object') {
|
||||
body.fehler = err.fehler
|
||||
const erster = Object.values(err.fehler).find((t) => typeof t === 'string')
|
||||
if (erster) body.error = erster
|
||||
}
|
||||
if (typeof err.code === 'string' && /^[A-Za-z0-9_]{1,40}$/.test(err.code) && body.code === undefined) body.code = err.code
|
||||
return res.status(err.status).json(body)
|
||||
}
|
||||
|
||||
@@ -47,11 +77,15 @@ const route = (fn) => async (req, res, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR })
|
||||
const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
|
||||
const anfrageLimiter = createWriteLimiter({ max: MAX_ANFRAGE_WRITES_PER_HOUR })
|
||||
const limitAnfrage = anfrageLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
|
||||
const schreibLimiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR })
|
||||
const limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
|
||||
|
||||
export function resetShopRoutesForTests() {
|
||||
limiter.reset()
|
||||
anfrageLimiter.reset()
|
||||
schreibLimiter.reset()
|
||||
setzeBildLimitZurueck()
|
||||
}
|
||||
|
||||
const nurKunde = (req, res, next) => {
|
||||
@@ -61,7 +95,7 @@ const nurKunde = (req, res, next) => {
|
||||
|
||||
const person = (req) => ({ name: req.session?.name || '', email: req.session?.email || '' })
|
||||
const projectIdOf = (req) => {
|
||||
const raw = req.query.projectId ?? req.body?.projectId
|
||||
const raw = req.params?.projectId ?? req.query.projectId ?? req.body?.projectId
|
||||
return typeof raw === 'string' ? raw : ''
|
||||
}
|
||||
|
||||
@@ -70,9 +104,57 @@ router.use((_req, res, next) => {
|
||||
next()
|
||||
})
|
||||
|
||||
const STRIPE_FELDER = ['charges_enabled', 'payouts_enabled', 'details_submitted', 'currently_due', 'past_due', 'disabled_reason', 'geprueftAt']
|
||||
const BEZAHLT = ['bezahlt', 'versendet', 'abholbereit', 'abgeschlossen']
|
||||
|
||||
/** Stripe-Status fuer den Browser: nur bekannte Felder, nie Konto-ID oder Schluessel. */
|
||||
function stripeAnsicht(shop) {
|
||||
const roh = shop?.stripeStatus
|
||||
const status = roh ? Object.fromEntries(STRIPE_FELDER.filter((k) => Object.hasOwn(roh, k)).map((k) => [k, roh[k]])) : null
|
||||
return { verbunden: Boolean(shop?.stripeAccountId), status }
|
||||
}
|
||||
|
||||
/** Kennzahlen der Uebersicht (nur Anzeige): Artikel und, best effort, Bestellungen. Ein Lesefehler bei den Bestellungen zaehlt als 0. */
|
||||
async function kennzahlen(shopId, produkte, now = new Date()) {
|
||||
const zahlen = {
|
||||
artikel: produkte.length,
|
||||
aktiveArtikel: produkte.filter((d) => d.aktiv !== false).length,
|
||||
offeneBestellungen: 0,
|
||||
bestellungen30: 0,
|
||||
umsatz30Cent: 0,
|
||||
}
|
||||
try {
|
||||
zahlen.offeneBestellungen = await countDocuments(config.collections.shopOrders, [Query.equal('shopId', shopId), Query.equal('status', 'bezahlt')])
|
||||
const seit = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
const neu = await listAllDocuments(config.collections.shopOrders, [Query.equal('shopId', shopId), Query.greaterThanEqual('erstelltAt', seit.toISOString())])
|
||||
for (const o of neu) {
|
||||
if (!BEZAHLT.includes(o.status)) continue
|
||||
zahlen.bestellungen30 += 1
|
||||
if (Number.isInteger(o.summeCent)) zahlen.umsatz30Cent += o.summeCent
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[shop] Kennzahlen der Bestellungen nicht lesbar: ${String(err?.message ?? '').slice(0, 120)}`)
|
||||
}
|
||||
return zahlen
|
||||
}
|
||||
|
||||
const einbauSnippet = (siteKey) => `<div data-webklar-shop="${siteKey}"></div>\n<script src="${config.publicBaseUrl}/embed/shop.js" defer></script>`
|
||||
const vorschauUrl = (siteKey) => `${config.publicBaseUrl}/shop/${siteKey}`
|
||||
const widerrufUrl = (siteKey) => `${config.publicBaseUrl}/shop/${siteKey}/widerruf`
|
||||
|
||||
async function ticketWoid(ticketId) {
|
||||
if (!ticketId) return null
|
||||
try {
|
||||
return (await getDocument(config.collections.workorders, ticketId))?.woid || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand des Webshops fuer ein Projekt: { verfuegbar, state, anfrage }. verfuegbar = Schalter fuer diesen Kunden;
|
||||
* state = Zustand der Funktion (off, wenn es noch kein Dokument gibt); anfrage = anfrageAnsicht oder null. Legt nichts an.
|
||||
* Stand des Webshops fuer ein Projekt. verfuegbar = Schalter fuer diesen Kunden; state = Zustand der Funktion (off ohne Dokument);
|
||||
* anfrage = anfrageAnsicht oder null; mit shopConfig dazu: ticketWoid, bereitschaft ({ bereit, punkte }), geoeffnet (verkauft der Shop
|
||||
* gerade), schalter (Schalter des Kunden), siteKey, snippet, vorschauUrl, stripe ({ verbunden, status }), zahlen. Legt nichts an.
|
||||
*/
|
||||
router.get('/status', requireSession, route(async (req, res) => {
|
||||
const projectId = projectIdOf(req)
|
||||
@@ -85,18 +167,38 @@ router.get('/status', requireSession, route(async (req, res) => {
|
||||
}
|
||||
const verfuegbar = shopFuerKunde(req.session?.email)
|
||||
const feature = await getFeatureDoc(customerId, projectId, 'shop')
|
||||
let shop = null
|
||||
if (feature) {
|
||||
try {
|
||||
shop = await getDocument(config.collections.shopConfig, feature.$id)
|
||||
} catch (err) {
|
||||
if (!(err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found')) throw err
|
||||
}
|
||||
const shopDoc = feature ? await leseShopConfig(feature.$id) : null
|
||||
const antwort = {
|
||||
verfuegbar,
|
||||
state: feature?.state || 'off',
|
||||
ticketWoid: null,
|
||||
anfrage: shopDoc ? anfrageAnsicht(shopDoc) : null,
|
||||
bereitschaft: null,
|
||||
geoeffnet: false,
|
||||
schalter: false,
|
||||
siteKey: feature?.siteKey || null,
|
||||
snippet: null,
|
||||
vorschauUrl: null,
|
||||
stripe: { verbunden: false, status: null },
|
||||
zahlen: null,
|
||||
}
|
||||
res.json({ verfuegbar, state: feature?.state || 'off', anfrage: shop ? anfrageAnsicht(shop) : null })
|
||||
if (feature && shopDoc) {
|
||||
const shop = ladeShop(shopDoc)
|
||||
const produkte = await listeProdukte(shop.id)
|
||||
const ctx = { doc: feature, shop, project: null }
|
||||
antwort.ticketWoid = await ticketWoid(feature.ticketId)
|
||||
antwort.bereitschaft = await bereitschaft(ctx, { produkte })
|
||||
antwort.geoeffnet = await istGeoeffnet(ctx, { produkte })
|
||||
antwort.schalter = shop.geoeffnet
|
||||
antwort.snippet = einbauSnippet(shop.siteKey || feature.siteKey)
|
||||
antwort.vorschauUrl = vorschauUrl(shop.siteKey || feature.siteKey)
|
||||
antwort.stripe = stripeAnsicht(shop)
|
||||
antwort.zahlen = await kennzahlen(shop.id, produkte)
|
||||
}
|
||||
res.json(antwort)
|
||||
}))
|
||||
|
||||
router.post('/anfrage', requireSession, nurKunde, limitWrites, route(async (req, res) => {
|
||||
router.post('/anfrage', requireSession, nurKunde, limitAnfrage, route(async (req, res) => {
|
||||
const anfrage = await stelleAnfrage({
|
||||
customerId: getSessionCustomerId(req),
|
||||
projectId: projectIdOf(req),
|
||||
@@ -106,9 +208,286 @@ router.post('/anfrage', requireSession, nurKunde, limitWrites, route(async (req,
|
||||
res.json({ anfrage })
|
||||
}))
|
||||
|
||||
router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitWrites, route(async (req, res) => {
|
||||
router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitAnfrage, route(async (req, res) => {
|
||||
const anfrage = await zieheZurueck({ customerId: getSessionCustomerId(req), projectId: projectIdOf(req), person: person(req) })
|
||||
res.json({ anfrage })
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------- Verwaltung (C7)
|
||||
|
||||
const stop = (status, text, code, fehlerFelder) =>
|
||||
Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}), ...(fehlerFelder ? { fehler: fehlerFelder } : {}) })
|
||||
const eingabeFehler = (fehlerFelder) => stop(400, Object.values(fehlerFelder)[0], undefined, fehlerFelder)
|
||||
const personLabel = (p) => (p.name ? `${p.name}${p.email ? ` <${p.email}>` : ''}` : p.email || 'Kunde')
|
||||
|
||||
/**
|
||||
* Gemeinsame Pruefung der Verwaltungs-Routen: projectId, eigenes Projekt (403; archiviert nur lesend), Schalter (409
|
||||
* nicht_verfuegbar), Funktion im Zustand zustaende (Vorgabe: schreibend setup|active, lesend setup|active|disabled; sonst 409
|
||||
* nicht_eingerichtet) und shopConfig (fehlt es und der Zustand ist setup|active, wird es angelegt).
|
||||
* @returns {Promise<{ customerId: string, projectId: string, project: object, doc: object, shopDoc: object, shop: object }>}
|
||||
*/
|
||||
async function mitShop(req, { schreiben = false, zustaende } = {}) {
|
||||
const projectId = projectIdOf(req)
|
||||
if (!projectId) throw stop(400, MELDUNG.projekt)
|
||||
const customerId = getSessionCustomerId(req)
|
||||
let project
|
||||
try {
|
||||
project = await assertOwnProject(customerId, projectId, { forNewOrder: schreiben })
|
||||
} catch (err) {
|
||||
throw err?.status === 403 || err?.status === 400 ? stop(403, MELDUNG.fremd) : err
|
||||
}
|
||||
if (!shopFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar')
|
||||
const erlaubt = zustaende || (schreiben ? ['setup', 'active'] : ['setup', 'active', 'disabled'])
|
||||
const doc = await getFeatureDoc(customerId, projectId, 'shop')
|
||||
if (!doc || !erlaubt.includes(doc.state)) throw stop(409, MELDUNG.nichtEingerichtet, 'nicht_eingerichtet')
|
||||
let shopDoc = await leseShopConfig(doc.$id)
|
||||
if (!shopDoc && ['setup', 'active'].includes(doc.state)) shopDoc = await ensureShopConfig(doc)
|
||||
if (!shopDoc) throw stop(409, MELDUNG.nichtEingerichtet, 'nicht_eingerichtet')
|
||||
return { customerId, projectId, project, doc, shopDoc, shop: ladeShop(shopDoc) }
|
||||
}
|
||||
|
||||
/** Middleware: mitShop vor dem Einlesen eines Uploads; Ergebnis in req.shopCtx. */
|
||||
const mitShopSchreibend = route(async (req, _res, next) => {
|
||||
req.shopCtx = await mitShop(req, { schreiben: true })
|
||||
next()
|
||||
})
|
||||
|
||||
const protokoll = (req, text) => logIfKundenansicht(req, 'shop_aenderung', text)
|
||||
|
||||
// ---- Einstellungen
|
||||
|
||||
const einstellungenAntwort = (shop) => ({ einstellungen: shop.einstellungen, version: shop.version, vollstaendig: istVollstaendig(shop.einstellungen) })
|
||||
|
||||
router.get('/einstellungen', requireSession, route(async (req, res) => {
|
||||
const { shop } = await mitShop(req)
|
||||
res.json(einstellungenAntwort(shop))
|
||||
}))
|
||||
|
||||
router.put('/einstellungen', requireSession, limitWrites, route(async (req, res) => {
|
||||
const { shop } = await mitShop(req, { schreiben: true })
|
||||
const gespeichert = await speichereEinstellungen(shop.id, req.body?.einstellungen, { erwarteteVersion: Number.isInteger(req.body?.version) ? req.body.version : undefined })
|
||||
await protokoll(req, 'Einstellungen gespeichert')
|
||||
res.json({ einstellungen: gespeichert.einstellungen, version: gespeichert.version, vollstaendig: istVollstaendig(gespeichert.einstellungen) })
|
||||
}))
|
||||
|
||||
// ---- Artikel
|
||||
|
||||
/** Artikel fuer den Haendler: alle gespeicherten Felder, dazu id, aktiv, sort und Vorschau-Adressen der Bilder. */
|
||||
function produktAnsicht(doc, projectId) {
|
||||
const p = parseProdukt(doc) || {}
|
||||
const bilder = Array.isArray(p.bilder) ? p.bilder.filter((b) => typeof b === 'string') : []
|
||||
return {
|
||||
...p,
|
||||
id: doc.$id,
|
||||
name: typeof p.name === 'string' ? p.name : doc.name || '',
|
||||
aktiv: doc.aktiv !== false,
|
||||
sort: Number.isInteger(doc.sort) ? doc.sort : 0,
|
||||
bilder,
|
||||
bildUrls: bilder.map((id) => ({
|
||||
id,
|
||||
klein: `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-s.webp`,
|
||||
gross: `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-l.webp`,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const bildIdsVon = (produkt) => (Array.isArray(produkt?.bilder) ? produkt.bilder.filter((b) => typeof b === 'string') : [])
|
||||
|
||||
/** Bilder, die nach einer Aenderung kein Artikel des Shops mehr verwendet, loeschen. */
|
||||
async function raeumeBilderAuf(shop, kandidaten) {
|
||||
if (kandidaten.length === 0) return
|
||||
const inGebrauch = await bildIdsInGebrauch(shop.id)
|
||||
for (const id of kandidaten) if (!inGebrauch.has(id)) await loescheBild(shop.siteKey, id)
|
||||
}
|
||||
|
||||
router.get('/produkte', requireSession, route(async (req, res) => {
|
||||
const { shop, projectId } = await mitShop(req)
|
||||
const docs = await listeProdukte(shop.id)
|
||||
res.json({ produkte: docs.map((d) => produktAnsicht(d, projectId)), maxArtikel: config.shop.maxArtikel, maxBilder: config.shop.maxBilder })
|
||||
}))
|
||||
|
||||
/** Artikel anlegen (id null) oder aendern: normalisieren, Bilder pruefen und beanspruchen, entfernte Bilder aufraeumen. */
|
||||
async function speichereArtikel(req, res, id) {
|
||||
const { shop, customerId, projectId } = await mitShop(req, { schreiben: true })
|
||||
const { produkt, fehler } = normalisiereProdukt(req.body?.produkt, { einstellungen: shop.einstellungen })
|
||||
if (fehler) throw eingabeFehler(fehler)
|
||||
const neueBilder = bildIdsVon(produkt)
|
||||
const doc = await withLocks([`shop-bilder:${shop.id}`], async () => {
|
||||
let vorher = []
|
||||
if (id) {
|
||||
const alt = await holeProdukt(shop.id, id)
|
||||
if (!alt) throw stop(404, 'Diesen Artikel gibt es nicht (mehr).')
|
||||
vorher = bildIdsVon(parseProdukt(alt))
|
||||
}
|
||||
for (const bildId of neueBilder) {
|
||||
const meta = await bildMeta(shop.siteKey, bildId)
|
||||
if (!meta || meta.shopId !== shop.id) throw eingabeFehler({ bilder: 'Eines der Bilder gibt es nicht (mehr). Bitte lade es neu hoch.' })
|
||||
}
|
||||
const gespeichert = await speichereProdukt({ shopId: shop.id, customerId }, id, produkt)
|
||||
await beanspruche(shop.siteKey, neueBilder)
|
||||
await raeumeBilderAuf(shop, vorher.filter((b) => !neueBilder.includes(b)))
|
||||
return gespeichert
|
||||
})
|
||||
await protokoll(req, `Artikel ${id ? 'geändert' : 'angelegt'}: ${produkt.name}`)
|
||||
res.status(id ? 200 : 201).json({ produkt: produktAnsicht(doc, projectId) })
|
||||
}
|
||||
|
||||
router.post('/produkte', requireSession, limitWrites, route((req, res) => speichereArtikel(req, res, null)))
|
||||
|
||||
router.post('/produkte/sortierung', requireSession, limitWrites, route(async (req, res) => {
|
||||
const { shop, projectId } = await mitShop(req, { schreiben: true })
|
||||
const docs = await sortiere(shop.id, req.body?.ids)
|
||||
await protokoll(req, 'Artikel sortiert')
|
||||
res.json({ produkte: docs.map((d) => produktAnsicht(d, projectId)) })
|
||||
}))
|
||||
|
||||
router.put('/produkte/:id', requireSession, limitWrites, route((req, res) => speichereArtikel(req, res, req.params.id)))
|
||||
|
||||
router.delete('/produkte/:id', requireSession, limitWrites, route(async (req, res) => {
|
||||
const { shop } = await mitShop(req, { schreiben: true })
|
||||
const name = await withLocks([`shop-bilder:${shop.id}`], async () => {
|
||||
const alt = await holeProdukt(shop.id, req.params.id)
|
||||
if (!alt) throw stop(404, 'Diesen Artikel gibt es nicht (mehr).')
|
||||
await loescheProdukt(shop.id, req.params.id)
|
||||
await raeumeBilderAuf(shop, bildIdsVon(parseProdukt(alt)))
|
||||
return alt.name || ''
|
||||
})
|
||||
await protokoll(req, `Artikel gelöscht: ${name}`)
|
||||
res.json({ ok: true })
|
||||
}))
|
||||
|
||||
// ---- Rechtstexte
|
||||
|
||||
/** Antwort der Rechtstexte: Entwurf, zuletzt bestaetigte Fassung, Muster zum Zuruecksetzen, erzeugte Texte und Hash des Entwurfs. */
|
||||
function rechtstexteAntwort(shop) {
|
||||
const e = shop.einstellungen
|
||||
const { entwurf, bestaetigt } = shop.rechtstexte
|
||||
const hash = rechtstexteHash(entwurf, e)
|
||||
return {
|
||||
entwurf,
|
||||
bestaetigt: bestaetigt ? { texte: bestaetigt.texte, at: bestaetigt.at, von: bestaetigt.von, aktuell: bestaetigt.hash === hash } : null,
|
||||
muster: {
|
||||
agb: musterAgb(e),
|
||||
widerrufsbelehrung: musterWiderrufsbelehrung(e, { widerrufUrl: widerrufUrl(shop.siteKey) }),
|
||||
widerrufsformular: musterWiderrufsformular(e),
|
||||
},
|
||||
versandUndZahlung: versandUndZahlung(e),
|
||||
gewaehrleistung: { bild: GEWAEHRLEISTUNG_BILD, text: gewaehrleistungsHinweis() },
|
||||
hash,
|
||||
bestaetigungText: BESTAETIGUNG_TEXT,
|
||||
vollstaendig: vollstaendig(entwurf),
|
||||
}
|
||||
}
|
||||
|
||||
/** shopConfig frisch lesen (unter der Sperre) und als geparsten Shop liefern. */
|
||||
async function frischerShop(shopId) {
|
||||
return ladeShop(await getDocument(config.collections.shopConfig, shopId))
|
||||
}
|
||||
|
||||
router.get('/rechtstexte', requireSession, route(async (req, res) => {
|
||||
const { shop } = await mitShop(req)
|
||||
res.json(rechtstexteAntwort(shop))
|
||||
}))
|
||||
|
||||
router.put('/rechtstexte', requireSession, limitWrites, route(async (req, res) => {
|
||||
const { shop } = await mitShop(req, { schreiben: true })
|
||||
const { rechtstexte, fehler } = normalisiereRechtstexte(req.body?.rechtstexte)
|
||||
if (fehler) throw eingabeFehler(fehler)
|
||||
const neu = await withLocks([`shop-rt:${shop.id}`], async () => {
|
||||
const aktuell = await frischerShop(shop.id)
|
||||
const json = JSON.stringify({ entwurf: rechtstexte, bestaetigt: aktuell.rechtstexte.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, { rechtstexte: json })
|
||||
return { ...aktuell, rechtstexte: { entwurf: rechtstexte, bestaetigt: aktuell.rechtstexte.bestaetigt } }
|
||||
})
|
||||
leereCache(shop.siteKey)
|
||||
await protokoll(req, 'Rechtstexte-Entwurf gespeichert')
|
||||
res.json(rechtstexteAntwort(neu))
|
||||
}))
|
||||
|
||||
router.post('/rechtstexte/bestaetigen', requireSession, nurKunde, limitWrites, route(async (req, res) => {
|
||||
const { shop } = await mitShop(req, { schreiben: true })
|
||||
const hash = typeof req.body?.hash === 'string' ? req.body.hash : ''
|
||||
const von = personLabel(person(req))
|
||||
const neu = await withLocks([`shop-rt:${shop.id}`], async () => {
|
||||
const aktuell = await frischerShop(shop.id)
|
||||
const { entwurf } = aktuell.rechtstexte
|
||||
if (hash === '' || hash !== rechtstexteHash(entwurf, aktuell.einstellungen)) {
|
||||
throw stop(409, 'Die Texte wurden inzwischen geändert. Bitte lies sie noch einmal durch und bestätige dann erneut.', 'texte_geaendert')
|
||||
}
|
||||
if (!vollstaendig(entwurf)) throw stop(400, 'Bitte fülle AGB, Widerrufsbelehrung, Widerrufsformular, Impressum und Datenschutz aus, bevor Du bestätigst.')
|
||||
const at = new Date().toISOString()
|
||||
const bestaetigt = { texte: { ...entwurf }, hash, 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, {
|
||||
rechtstexte: json,
|
||||
rechtstexteBestaetigtAt: at,
|
||||
rechtstexteBestaetigtVon: von,
|
||||
rechtstexteHash: hash,
|
||||
})
|
||||
return { ...aktuell, rechtstexte: { entwurf, bestaetigt } }
|
||||
})
|
||||
leereCache(shop.siteKey)
|
||||
res.json(rechtstexteAntwort(neu))
|
||||
}))
|
||||
|
||||
// ---- Shop oeffnen und schliessen
|
||||
|
||||
router.post('/oeffnen', requireSession, nurKunde, limitWrites, route(async (req, res) => {
|
||||
if (typeof req.body?.offen !== 'boolean') throw stop(400, 'Bitte wähle, ob der Shop geöffnet oder geschlossen sein soll.')
|
||||
const { shop, doc } = await mitShop(req, { schreiben: true })
|
||||
const produkte = await listeProdukte(shop.id)
|
||||
const ctx = { doc, shop, project: null }
|
||||
if (req.body.offen) {
|
||||
const stand = await bereitschaft(ctx, { produkte })
|
||||
if (!stand.bereit) throw Object.assign(stop(409, 'Der Shop kann noch nicht geöffnet werden. Bitte erledige zuerst die offenen Punkte.', 'nicht_bereit'), { body: { code: 'nicht_bereit', punkte: stand.punkte } })
|
||||
}
|
||||
await updateDocument(config.collections.shopConfig, shop.id, { geoeffnet: req.body.offen, geoeffnetAt: new Date().toISOString() })
|
||||
leereCache(shop.siteKey)
|
||||
const neu = { ...shop, geoeffnet: req.body.offen }
|
||||
res.json({ schalter: req.body.offen, geoeffnet: await istGeoeffnet({ doc, shop: neu, project: null }, { produkte }), bereitschaft: await bereitschaft({ doc, shop: neu, project: null }, { produkte }) })
|
||||
}))
|
||||
|
||||
// ---- Bilder
|
||||
|
||||
// Bild als rohe Bytes (application/octet-stream), hoechstens 8 MB; die Pruefung des Shops laeuft vor dem Einlesen
|
||||
router.post(
|
||||
'/bild',
|
||||
requireSession,
|
||||
limitWrites,
|
||||
mitShopSchreibend,
|
||||
(req, res, next) => {
|
||||
if (bildLimitErreicht(getSessionCustomerId(req))) return res.status(429).json({ error: MELDUNG.zuVieleBilder })
|
||||
return next()
|
||||
},
|
||||
uploadSlot,
|
||||
express.raw({ type: 'application/octet-stream', limit: BILD_MAX_BYTES }),
|
||||
route(async (req, res) => {
|
||||
const { shop, customerId } = req.shopCtx
|
||||
const bild = await speichereBild({ customerId, shopId: shop.id, siteKey: shop.siteKey, buffer: req.body })
|
||||
await protokoll(req, 'Bild hochgeladen')
|
||||
res.status(201).json({ imageId: bild.imageId, w: bild.w, h: bild.h })
|
||||
})
|
||||
)
|
||||
|
||||
// Vorschau im Portal: nur fuer den Kunden, dem das Projekt gehoert
|
||||
router.get('/bild/:projectId/:datei', requireSession, route(async (req, res) => {
|
||||
const { shop } = await mitShop(req)
|
||||
const treffer = BILD_DATEI_RE.exec(req.params.datei)
|
||||
const datei = treffer && (await bildMeta(shop.siteKey, treffer[1])) && bildPfad(shop.siteKey, treffer[1], treffer[2])
|
||||
if (!datei) return res.status(404).json({ error: MELDUNG.bildFehlt })
|
||||
res.set({ 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' })
|
||||
return res.sendFile(datei, (err) => {
|
||||
if (err && !res.headersSent) res.status(404).json({ error: MELDUNG.bildFehlt })
|
||||
})
|
||||
}))
|
||||
|
||||
// Fehler beim Einlesen eines Uploads (zu gross, abgebrochen): verstaendlich beantworten, nie Rohtext
|
||||
router.use((err, _req, res, next) => {
|
||||
if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'Das Bild ist größer als 8 MB.' })
|
||||
if (err?.status >= 400 && err.status < 500 && res.headersSent === false) return res.status(err.status).json({ error: 'Die Anfrage ist ungültig.' })
|
||||
return next(err)
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -16,6 +16,7 @@ export const VIEW_ACTIONS = [
|
||||
'termin_einstellungen',
|
||||
'termin_aktion',
|
||||
'termin_manuell',
|
||||
'shop_aenderung',
|
||||
]
|
||||
|
||||
const DETAIL_MAX = 300
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import sharp from 'sharp'
|
||||
import { config } from '../../config.js'
|
||||
|
||||
/**
|
||||
* Artikelbilder des Webshops (Plan 2026-10-05-webshop.md, Task C7; verallgemeinert aus services/newsImages.js, das unveraendert
|
||||
* bleibt). Jedes Bild wird neu kodiert (WebP, laengste Kante 1600 px, dazu Vorschau 480 px); dabei fallen alle Metadaten (GPS,
|
||||
* Kamera, Drehung) weg. Nur JPEG, PNG und WebP, hoechstens 8 MB.
|
||||
*
|
||||
* Ablage: <config.shop.uploadDir>/<siteKey>/<id>-l.webp, <id>-s.webp und Sidecar <id>.json
|
||||
* { customerId, shopId, createdAt, claimed, w, h }. Der Sidecar wird zuletzt geschrieben: ohne ihn gilt ein Bild als nicht
|
||||
* vorhanden. Hochgeladene, nie an einen Artikel gebundene Bilder loescht raeumeUnbeanspruchteAuf() nach 24 h.
|
||||
* Fehler tragen status (400/413/415) und einen deutschen Text.
|
||||
*/
|
||||
|
||||
export const BILD_MAX_BYTES = 8 * 1024 * 1024
|
||||
const UNBEANSPRUCHT_MS = 24 * 60 * 60 * 1000
|
||||
const MAX_UPLOADS_PRO_STUNDE = 40
|
||||
const ID_RE = /^[a-z0-9-]{1,64}$/
|
||||
const SITE_KEY_RE = /^[a-z2-7]{16}$/
|
||||
// Hoechstens 40 Megapixel je Bild: ein winziges PNG/WebP kann sonst Gigabytes beim Dekodieren belegen
|
||||
const MAX_INPUT_PIXELS = 40_000_000
|
||||
// Fehlertexte von sharp/libvips bei kaputten, unbekannten oder zu grossen Bildern
|
||||
const DECODE_ERROR_RE = /unsupported image format|pixel limit|corrupt|truncated|invalid|bad seek|bad (?:header|format)|not a|Input (?:buffer|file|Buffer)|Vips|heif|jpeg|png|webp|premature end|decode/i
|
||||
const FORMATE = new Set(['jpeg', 'png', 'webp'])
|
||||
|
||||
const fehler = (status, text) => Object.assign(new Error(text), { status })
|
||||
const idOk = (id) => typeof id === 'string' && ID_RE.test(id)
|
||||
const keyOk = (siteKey) => typeof siteKey === 'string' && SITE_KEY_RE.test(siteKey)
|
||||
const wurzel = () => path.resolve(config.shop.uploadDir)
|
||||
const ordner = (siteKey) => path.join(wurzel(), siteKey)
|
||||
const sidecar = (siteKey, id) => path.join(ordner(siteKey), `${id}.json`)
|
||||
|
||||
/** Pfad zu einer Bilddatei ('l' gross, 's' Vorschau) oder null bei unsauberem siteKey, unsauberer ID oder Groesse. */
|
||||
export function bildPfad(siteKey, imageId, groesse) {
|
||||
if (!keyOk(siteKey) || !idOk(imageId)) return null
|
||||
if (groesse !== 'l' && groesse !== 's') return null
|
||||
return path.join(ordner(siteKey), `${imageId}-${groesse}.webp`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bild kodieren und ablegen.
|
||||
* @returns {Promise<{ imageId: string, w: number, h: number }>}
|
||||
*/
|
||||
export async function speichereBild({ customerId, shopId, siteKey, buffer }) {
|
||||
if (!keyOk(siteKey)) throw fehler(400, 'Ungültiger Shop.')
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw fehler(415, 'Bitte lade ein Foto hoch (JPEG, PNG oder WebP).')
|
||||
if (buffer.length > BILD_MAX_BYTES) throw fehler(413, 'Das Bild ist größer als 8 MB.')
|
||||
|
||||
const nichtUnterstuetzt = () => fehler(415, 'Dieses Dateiformat können wir nicht verwenden. Bitte lade ein JPEG, PNG oder WebP hoch.')
|
||||
let gross, klein
|
||||
try {
|
||||
const opts = { failOn: 'error', limitInputPixels: MAX_INPUT_PIXELS }
|
||||
const meta = await sharp(buffer, opts).metadata()
|
||||
if (!FORMATE.has(meta.format)) throw nichtUnterstuetzt()
|
||||
// rotate() wendet die EXIF-Drehung an; ohne withMetadata() schreibt sharp keine Metadaten
|
||||
gross = await sharp(buffer, opts).rotate()
|
||||
.resize({ width: 1600, height: 1600, fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 80 }).toBuffer({ resolveWithObject: true })
|
||||
// Vorschau aus dem schon verkleinerten Ergebnis (spart eine Dekodierung des Originals)
|
||||
klein = await sharp(gross.data)
|
||||
.resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 75 }).toBuffer()
|
||||
} catch (err) {
|
||||
if (err.status) throw err
|
||||
if (DECODE_ERROR_RE.test(String(err && err.message))) throw nichtUnterstuetzt()
|
||||
console.error(`[shop] Bild-Verarbeitung fehlgeschlagen: ${String(err?.message ?? '').slice(0, 200)}`)
|
||||
throw err
|
||||
}
|
||||
|
||||
const imageId = randomUUID()
|
||||
const { width: w, height: h } = gross.info
|
||||
await mkdir(ordner(siteKey), { recursive: true })
|
||||
await writeFile(bildPfad(siteKey, imageId, 'l'), gross.data)
|
||||
await writeFile(bildPfad(siteKey, imageId, 's'), klein)
|
||||
await schreibeSidecar(siteKey, imageId, { customerId, shopId, createdAt: new Date().toISOString(), claimed: false, w, h })
|
||||
return { imageId, w, h }
|
||||
}
|
||||
|
||||
async function schreibeSidecar(siteKey, imageId, meta) {
|
||||
const datei = sidecar(siteKey, imageId)
|
||||
await writeFile(`${datei}.tmp`, JSON.stringify(meta))
|
||||
await rename(`${datei}.tmp`, datei)
|
||||
}
|
||||
|
||||
/** Sidecar eines Bildes { customerId, shopId, createdAt, claimed, w, h } oder null, wenn es das Bild nicht gibt. */
|
||||
export async function bildMeta(siteKey, imageId) {
|
||||
if (!keyOk(siteKey) || !idOk(imageId)) return null
|
||||
try {
|
||||
const meta = JSON.parse(await readFile(sidecar(siteKey, imageId), 'utf8'))
|
||||
return meta && typeof meta === 'object' && !Array.isArray(meta) ? meta : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Markiert Bilder als an einen Artikel gebunden (werden nicht mehr aufgeraeumt). Unbekannte IDs werden uebergangen. */
|
||||
export async function beanspruche(siteKey, ids) {
|
||||
for (const id of Array.isArray(ids) ? ids : []) {
|
||||
const meta = await bildMeta(siteKey, id)
|
||||
if (!meta || meta.claimed === true) continue
|
||||
await schreibeSidecar(siteKey, id, { ...meta, claimed: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** Loescht ein Bild (beide Groessen und Sidecar); fehlt es, passiert nichts. */
|
||||
export async function loescheBild(siteKey, imageId) {
|
||||
if (!keyOk(siteKey) || !idOk(imageId)) return
|
||||
await Promise.all([
|
||||
rm(bildPfad(siteKey, imageId, 'l'), { force: true }),
|
||||
rm(bildPfad(siteKey, imageId, 's'), { force: true }),
|
||||
rm(sidecar(siteKey, imageId), { force: true }),
|
||||
rm(`${sidecar(siteKey, imageId)}.tmp`, { force: true }),
|
||||
])
|
||||
}
|
||||
|
||||
/** Loescht alle Bilder eines Shops (Ordner samt Inhalt), z. B. beim Entfernen der Shop-Daten. */
|
||||
export async function loescheAlleBilder(siteKey) {
|
||||
if (!keyOk(siteKey)) return
|
||||
await rm(ordner(siteKey), { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/** Loescht nicht beanspruchte Bilder, die aelter als 24 h sind (Sidecar ohne Datum gilt als alt). Gibt die Anzahl zurueck. */
|
||||
export async function raeumeUnbeanspruchteAuf(now = new Date()) {
|
||||
const jetzt = now instanceof Date ? now.getTime() : Number(now)
|
||||
const shops = await readdir(wurzel()).catch(() => [])
|
||||
let entfernt = 0
|
||||
for (const siteKey of shops) {
|
||||
if (!keyOk(siteKey)) continue
|
||||
const dateien = await readdir(ordner(siteKey)).catch(() => [])
|
||||
for (const datei of dateien) {
|
||||
if (!datei.endsWith('.json')) continue
|
||||
const id = datei.slice(0, -5)
|
||||
if (!idOk(id)) continue
|
||||
const meta = await bildMeta(siteKey, id)
|
||||
if (!meta || meta.claimed === true) continue
|
||||
const erstellt = Date.parse(meta.createdAt)
|
||||
if (Number.isFinite(erstellt) && jetzt - erstellt <= UNBEANSPRUCHT_MS) continue
|
||||
await loescheBild(siteKey, id)
|
||||
entfernt++
|
||||
}
|
||||
}
|
||||
return entfernt
|
||||
}
|
||||
|
||||
// Missbrauchsschutz: Uploads je Kunde und Stunde (im Speicher, reicht fuer einen Prozess). Jeder Aufruf zaehlt.
|
||||
const uploads = new Map()
|
||||
export function bildLimitErreicht(customerId) {
|
||||
const jetzt = Date.now()
|
||||
for (const [key, zeiten] of uploads) {
|
||||
const frisch = zeiten.filter((t) => jetzt - t < 60 * 60 * 1000)
|
||||
if (frisch.length === 0) uploads.delete(key)
|
||||
else if (frisch.length !== zeiten.length) uploads.set(key, frisch)
|
||||
}
|
||||
const liste = uploads.get(customerId) || []
|
||||
liste.push(jetzt)
|
||||
uploads.set(customerId, liste)
|
||||
return liste.length > MAX_UPLOADS_PRO_STUNDE
|
||||
}
|
||||
|
||||
/** Setzt den Upload-Zaehler zurueck (Tests). */
|
||||
export function setzeBildLimitZurueck() {
|
||||
uploads.clear()
|
||||
}
|
||||
@@ -295,7 +295,7 @@ export function normalisiereProdukt(eingabe, { einstellungen } = {}) {
|
||||
}
|
||||
|
||||
/** Gespeicherten JSON-Text sicher lesen; null bei kaputten Daten. */
|
||||
function parseProdukt(doc) {
|
||||
export function parseProdukt(doc) {
|
||||
try {
|
||||
const p = JSON.parse(doc?.produkt)
|
||||
return istObjekt(p) ? p : null
|
||||
|
||||
@@ -17,10 +17,10 @@ const entries = () => [...aw.docs('customerViewLog').values()]
|
||||
const seedLog = (id, customerId, createdAt, action = 'geoeffnet') =>
|
||||
aw.seed('customerViewLog', { $id: id, customerId, staffUserId: 'u_staff2', staffName: 'Andrej', action, detail: '', createdAt })
|
||||
|
||||
test('VIEW_ACTIONS: genau die zehn Aktionen', () => {
|
||||
test('VIEW_ACTIONS: genau die elf Aktionen', () => {
|
||||
assert.deepEqual(VIEW_ACTIONS, [
|
||||
'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht',
|
||||
'termin_einstellungen', 'termin_aktion', 'termin_manuell',
|
||||
'termin_einstellungen', 'termin_aktion', 'termin_manuell', 'shop_aenderung',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ test('Checkout je Funktion ist in der Kundenansicht gesperrt (Schleife trifft /a
|
||||
}
|
||||
})
|
||||
|
||||
test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und Login', () => {
|
||||
test('Erlaubnisliste: genau News, Terminplaner, Webshop-Verwaltung, Analyse-Link, Ende, Logout und Login', () => {
|
||||
const ok = [
|
||||
['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'],
|
||||
['POST', '/api/funktionen/analyse/heatmap-link'], ['POST', '/api/auth/kundenansicht/ende'],
|
||||
@@ -127,9 +127,11 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L
|
||||
['POST', '/api/termine/buchungen'], ['POST', '/api/termine/buchungen/abc/aktion'], ['POST', '/api/termine/ressourcen/abc/feed'],
|
||||
['PATCH', '/api/termine/einstellungen'], ['PATCH', '/api/termine/einstellungen/'],
|
||||
['DELETE', '/api/termine/buchungen/abc'], ['DELETE', '/api/termine/buchungen/abc/'],
|
||||
['POST', '/api/shop/produkte'], ['POST', '/api/shop/produkte/'], ['POST', '/api/shop/produkte/sortierung'], ['PUT', '/api/shop/produkte/abc'],
|
||||
['DELETE', '/api/shop/produkte/abc'], ['PUT', '/api/shop/einstellungen'], ['PUT', '/api/shop/rechtstexte'], ['POST', '/api/shop/bild'],
|
||||
]
|
||||
for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`)
|
||||
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6, 'genau sechs Termine-Eintraege dazu, keine Wildcard')
|
||||
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6 + 7, 'genau sechs Termine- und sieben Webshop-Eintraege dazu, keine Wildcard')
|
||||
for (const [m, p] of [
|
||||
['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout'],
|
||||
// Terminplaner: nur der eigene Pfad, nur diese Methoden und Formen
|
||||
@@ -143,6 +145,12 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L
|
||||
['DELETE', '/api/termine/ressourcen/abc'], ['PUT', '/api/termine/buchungen/abc'],
|
||||
// Webshop: Anfrage stellen und zurueckziehen sind in der Kundenansicht gesperrt (nicht auf der Liste)
|
||||
['POST', '/api/shop/anfrage'], ['POST', '/api/shop/anfrage/zurueckziehen'],
|
||||
// Webshop: Oeffnen und Bestaetigen der Rechtstexte macht der Haendler selbst; Stripe und Erstattungen sind nie dabei
|
||||
['POST', '/api/shop/oeffnen'], ['POST', '/api/shop/rechtstexte/bestaetigen'], ['POST', '/api/shop/stripe/verbinden'], ['POST', '/api/shop/bestellungen/abc/erstatten'],
|
||||
// und nur die ausgeschriebenen Pfade und Methoden der Verwaltung
|
||||
['POST', '/api/shop/rechtstexte'], ['POST', '/api/shop/einstellungen'], ['PATCH', '/api/shop/produkte/abc'], ['POST', '/api/shop/produkte/abc'],
|
||||
['PUT', '/api/shop/produkte'], ['DELETE', '/api/shop/produkte'], ['PUT', '/api/shop/bild'], ['POST', '/api/shop/bild/x'],
|
||||
['POST', '/api/shopX/produkte'], ['PUT', '/api/shop/produkte/abc/def'], ['DELETE', '/api/shop/einstellungen'],
|
||||
]) {
|
||||
assert.ok(!allowed(m, p), `${m} ${p}`)
|
||||
}
|
||||
@@ -175,6 +183,25 @@ test('unbekanntes POST/PUT unter /api/termine in der Kundenansicht -> 403 view_b
|
||||
}
|
||||
})
|
||||
|
||||
// Router-Abgleich Webshop: genau die sieben Verwaltungs-Schreib-Routen stehen auf der Liste, jeder Eintrag trifft genau eine echte Route
|
||||
test('Router-Abgleich: genau sieben Schreib-Routen des Webshops stehen auf der Erlaubnisliste', () => {
|
||||
const shopRouter = MOUNTS.find(([prefix]) => prefix === '/api/shop')[1]
|
||||
const routen = writeRoutes([['/api/shop', shopRouter]])
|
||||
assert.deepEqual(
|
||||
routen.filter(({ method, path: p }) => allowed(method, p)).map(({ method, path: p }) => `${method} ${p}`).sort(),
|
||||
['DELETE /api/shop/produkte/x', 'POST /api/shop/bild', 'POST /api/shop/produkte', 'POST /api/shop/produkte/sortierung',
|
||||
'PUT /api/shop/einstellungen', 'PUT /api/shop/produkte/x', 'PUT /api/shop/rechtstexte']
|
||||
)
|
||||
const shopEintraege = KUNDENANSICHT_ALLOWED.filter(([, re]) => re.source.includes('shop'))
|
||||
assert.equal(shopEintraege.length, 7)
|
||||
for (const [m, re] of shopEintraege) assert.equal(routen.filter((r) => r.method === m && re.test(r.path)).length, 1, `${m} ${re}`)
|
||||
// gesperrt bleiben insbesondere Anfrage, Oeffnen und Bestaetigen
|
||||
for (const p of ['/api/shop/anfrage', '/api/shop/anfrage/zurueckziehen', '/api/shop/oeffnen', '/api/shop/rechtstexte/bestaetigen']) {
|
||||
assert.ok(routen.some((r) => r.method === 'POST' && r.path === p), `Route ${p} existiert`)
|
||||
assert.ok(!allowed('POST', p), p)
|
||||
}
|
||||
})
|
||||
|
||||
// Router-Abgleich: die Erlaubnisliste und die echten Schreib-Routen des Terminplaners decken sich genau; eine neue Route faellt
|
||||
// im Test "jede Schreib-Route ausserhalb der Erlaubnisliste -> 403" auf (sie muss gesperrt sein), ein toter Eintrag hier
|
||||
test('Router-Abgleich: genau sechs Schreib-Routen des Terminplaners stehen auf der Erlaubnisliste', () => {
|
||||
|
||||
@@ -105,7 +105,10 @@ test('GET /status: ohne Dokumente off und leer, legt nichts an; Schalter, fremde
|
||||
await leereAlles()
|
||||
const res = await status()
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(res.body, { verfuegbar: true, state: 'off', anfrage: null })
|
||||
assert.deepEqual(res.body, {
|
||||
verfuegbar: true, state: 'off', ticketWoid: null, anfrage: null, bereitschaft: null, geoeffnet: false, schalter: false, siteKey: null,
|
||||
snippet: null, vorschauUrl: null, stripe: { verbunden: false, status: null }, zahlen: null,
|
||||
})
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||||
assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0, 'GET legt nichts an')
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync, existsSync } from 'node:fs'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import sharp from 'sharp'
|
||||
|
||||
// Webshop C7: Artikelbilder (services/shop/bilder.js), verallgemeinert aus newsImages.js
|
||||
process.env.SHOP_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'shop-bilder-'))
|
||||
const {
|
||||
speichereBild, bildPfad, bildMeta, beanspruche, loescheBild, loescheAlleBilder, raeumeUnbeanspruchteAuf, bildLimitErreicht,
|
||||
setzeBildLimitZurueck, BILD_MAX_BYTES,
|
||||
} = await import('../server/services/shop/bilder.js')
|
||||
|
||||
const KEY = 'abcdefghijklmnop'
|
||||
const KEY2 = 'qrstuvwxyz234567'
|
||||
const solid = (width, height) => sharp({ create: { width, height, channels: 3, background: '#36c' } })
|
||||
const speichere = (buffer, extra = {}) => speichereBild({ customerId: 'c1', shopId: 's1', siteKey: KEY, buffer, ...extra })
|
||||
|
||||
test('EXIF/GPS entfernt und gedreht; Sidecar mit Besitzer, Shop und Groesse', async () => {
|
||||
const input = await solid(1200, 800)
|
||||
.withMetadata({ orientation: 6 })
|
||||
.withExif({ IFD3: { GPSLatitudeRef: 'N', GPSLatitude: '48/1 30/1 0/1' } })
|
||||
.jpeg().toBuffer()
|
||||
assert.ok((await sharp(input).metadata()).exif, 'Fixture hat EXIF')
|
||||
const { imageId, w, h } = await speichere(input)
|
||||
assert.ok(w < h, 'gedreht')
|
||||
for (const size of ['l', 's']) {
|
||||
const meta = await sharp(bildPfad(KEY, imageId, size)).metadata()
|
||||
assert.equal(meta.exif, undefined)
|
||||
assert.equal(meta.format, 'webp')
|
||||
}
|
||||
const meta = await bildMeta(KEY, imageId)
|
||||
assert.deepEqual([meta.customerId, meta.shopId, meta.claimed, meta.w, meta.h], ['c1', 's1', false, w, h])
|
||||
assert.match(meta.createdAt, /^\d{4}-\d\d-\d\dT/)
|
||||
})
|
||||
|
||||
test('verkleinert auf 1600 und 480, Dateinamen <id>-l.webp und <id>-s.webp unter <uploadDir>/<siteKey>/', async () => {
|
||||
const { imageId, w, h } = await speichere(await solid(4000, 3000).png().toBuffer())
|
||||
assert.deepEqual([w, h], [1600, 1200])
|
||||
const l = await sharp(bildPfad(KEY, imageId, 'l')).metadata()
|
||||
const s = await sharp(bildPfad(KEY, imageId, 's')).metadata()
|
||||
assert.deepEqual([l.width, l.height, s.width, s.height], [1600, 1200, 480, 360])
|
||||
assert.equal(bildPfad(KEY, imageId, 'l'), path.join(process.env.SHOP_UPLOAD_DIR, KEY, `${imageId}-l.webp`))
|
||||
// kleine Bilder werden nicht vergroessert
|
||||
const klein = await speichere(await solid(100, 50).webp().toBuffer())
|
||||
assert.deepEqual([klein.w, klein.h], [100, 50])
|
||||
})
|
||||
|
||||
test('zu gross 413, falsches Format 415 (PDF, GIF, kaputt, leer, kein Buffer), ungueltiger Shop 400', async () => {
|
||||
await assert.rejects(speichere(Buffer.alloc(BILD_MAX_BYTES + 1)), { status: 413 })
|
||||
assert.equal(BILD_MAX_BYTES, 8 * 1024 * 1024)
|
||||
await assert.rejects(speichere(Buffer.from('%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n')), { status: 415 })
|
||||
await assert.rejects(speichere(await solid(10, 10).gif().toBuffer()), { status: 415 })
|
||||
await assert.rejects(speichere(Buffer.from('kein Bild')), { status: 415 })
|
||||
await assert.rejects(speichere(Buffer.alloc(0)), { status: 415 })
|
||||
await assert.rejects(speichere({}), { status: 415 })
|
||||
await assert.rejects(speichere(await solid(10, 10).png().toBuffer(), { siteKey: '../x' }), { status: 400 })
|
||||
})
|
||||
|
||||
test('Pfade nur fuer saubere siteKeys, IDs und Groessen', () => {
|
||||
assert.equal(bildPfad(KEY, '../x', 'l'), null)
|
||||
assert.equal(bildPfad(KEY, 'a/b', 'l'), null)
|
||||
assert.equal(bildPfad(KEY, 'ABC', 'l'), null)
|
||||
assert.equal(bildPfad('../abcdefghijkl', 'abc-1', 'l'), null)
|
||||
assert.equal(bildPfad('abcdefghijklmno', 'abc-1', 'l'), null, 'zu kurz')
|
||||
assert.equal(bildPfad('abcdefghijklmnop1', 'abc-1', 'l'), null, 'zu lang')
|
||||
assert.equal(bildPfad('abcdefghijklmno1', 'abc-1', 'l'), null, 'Ziffer 1 ist kein base32')
|
||||
assert.equal(bildPfad(KEY, 'abc-1', 'xl'), null)
|
||||
assert.equal(bildPfad(KEY, 'x'.repeat(65), 'l'), null)
|
||||
assert.ok(bildPfad(KEY, 'abc-1', 's').endsWith(path.join(KEY, 'abc-1-s.webp')))
|
||||
})
|
||||
|
||||
test('bildMeta: unbekannte oder unsaubere IDs => null; beanspruche setzt claimed, ignoriert Unbekanntes und Wiederholung', async () => {
|
||||
const { imageId } = await speichere(await solid(50, 50).png().toBuffer())
|
||||
assert.equal(await bildMeta(KEY, 'gibtsnicht'), null)
|
||||
assert.equal(await bildMeta(KEY, '../x'), null)
|
||||
assert.equal(await bildMeta('zu-kurz', imageId), null)
|
||||
await beanspruche(KEY, [imageId, 'gibtsnicht', '../x', 42])
|
||||
assert.equal((await bildMeta(KEY, imageId)).claimed, true)
|
||||
await beanspruche(KEY, [imageId])
|
||||
assert.equal((await bildMeta(KEY, imageId)).claimed, true)
|
||||
await beanspruche(KEY, undefined)
|
||||
})
|
||||
|
||||
test('loescheBild entfernt beide Groessen und den Sidecar; loescheAlleBilder den ganzen Shop; Fehlendes ist kein Fehler', async () => {
|
||||
const a = await speichere(await solid(50, 50).png().toBuffer())
|
||||
const b = await speichere(await solid(50, 50).png().toBuffer())
|
||||
const andere = await speichereBild({ customerId: 'c2', shopId: 's2', siteKey: KEY2, buffer: await solid(50, 50).png().toBuffer() })
|
||||
await loescheBild(KEY, a.imageId)
|
||||
await loescheBild(KEY, a.imageId)
|
||||
await loescheBild(KEY, '../x')
|
||||
for (const size of ['l', 's']) assert.equal(existsSync(bildPfad(KEY, a.imageId, size)), false)
|
||||
assert.equal(await bildMeta(KEY, a.imageId), null)
|
||||
assert.ok(await bildMeta(KEY, b.imageId))
|
||||
await loescheAlleBilder(KEY)
|
||||
await loescheAlleBilder('../x')
|
||||
await loescheAlleBilder('gibtsnicht1234567')
|
||||
assert.equal(await bildMeta(KEY, b.imageId), null)
|
||||
assert.equal(existsSync(path.join(process.env.SHOP_UPLOAD_DIR, KEY)), false)
|
||||
assert.ok(await bildMeta(KEY2, andere.imageId), 'anderer Shop bleibt')
|
||||
})
|
||||
|
||||
test('raeumeUnbeanspruchteAuf: nur Unbeanspruchte aelter als 24 h, in allen Shops', async () => {
|
||||
const png = await solid(50, 50).png().toBuffer()
|
||||
const alt = await speichere(png)
|
||||
const behalten = await speichere(png)
|
||||
const frisch = await speichere(png)
|
||||
const altAnderer = await speichereBild({ customerId: 'c2', shopId: 's2', siteKey: KEY2, buffer: png })
|
||||
await beanspruche(KEY, [behalten.imageId])
|
||||
const zurueckdatieren = async (key, id, stunden) => {
|
||||
const datei = path.join(process.env.SHOP_UPLOAD_DIR, key, `${id}.json`)
|
||||
const meta = JSON.parse(await readFile(datei, 'utf8'))
|
||||
meta.createdAt = new Date(Date.now() - stunden * 3600 * 1000).toISOString()
|
||||
await writeFile(datei, JSON.stringify(meta))
|
||||
}
|
||||
await zurueckdatieren(KEY, alt.imageId, 25)
|
||||
await zurueckdatieren(KEY, behalten.imageId, 48)
|
||||
await zurueckdatieren(KEY, frisch.imageId, 23)
|
||||
await zurueckdatieren(KEY2, altAnderer.imageId, 30)
|
||||
assert.equal(await raeumeUnbeanspruchteAuf(), 2)
|
||||
assert.equal(await bildMeta(KEY, alt.imageId), null)
|
||||
assert.equal(existsSync(bildPfad(KEY, alt.imageId, 'l')), false)
|
||||
assert.equal(await bildMeta(KEY2, altAnderer.imageId), null)
|
||||
assert.ok(await bildMeta(KEY, behalten.imageId), 'beansprucht bleibt')
|
||||
assert.ok(await bildMeta(KEY, frisch.imageId), 'frisch bleibt')
|
||||
// mit spaeterem Zeitpunkt wird auch das frische geraeumt
|
||||
assert.equal(await raeumeUnbeanspruchteAuf(new Date(Date.now() + 3 * 3600 * 1000)), 1)
|
||||
assert.equal(await bildMeta(KEY, frisch.imageId), null)
|
||||
})
|
||||
|
||||
test('bildLimitErreicht: 40 je Stunde und Kunde, danach true; andere Kunden unberuehrt', () => {
|
||||
setzeBildLimitZurueck()
|
||||
for (let i = 0; i < 40; i++) assert.equal(bildLimitErreicht('c1'), false, `Aufruf ${i + 1}`)
|
||||
assert.equal(bildLimitErreicht('c1'), true)
|
||||
assert.equal(bildLimitErreicht('c2'), false)
|
||||
setzeBildLimitZurueck()
|
||||
assert.equal(bildLimitErreicht('c1'), false)
|
||||
})
|
||||
@@ -0,0 +1,631 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync, existsSync } from 'node:fs'
|
||||
import { readdir, rm } from 'node:fs/promises'
|
||||
import sharp from 'sharp'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
|
||||
|
||||
// Webshop C7: Verwaltungs-Routen /api/shop (Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder), Kundenansicht
|
||||
process.env.SHOP_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'shop-routes-'))
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
shopConfig: schemaKeys('shopConfig'),
|
||||
shopProducts: schemaKeys('shopProducts'),
|
||||
customerViewLog: schemaKeys('customerViewLog'),
|
||||
},
|
||||
datetimes: SHOP_DATETIMES,
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, send, post } = await import('./support/testApp.js')
|
||||
const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js')
|
||||
const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js')
|
||||
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
|
||||
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 { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
|
||||
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
|
||||
|
||||
const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
|
||||
seedFixtures(aw)
|
||||
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Töpferei', archived: false })
|
||||
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' })
|
||||
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' })
|
||||
aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Altprojekt', archived: true })
|
||||
|
||||
const cookie = customerCookie('c1')
|
||||
const KV = { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } }
|
||||
const EINSTELLUNGEN = {
|
||||
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
|
||||
shopname: 'Töpferei Keramik',
|
||||
anbieter: { name: 'Töpferei Keramik', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@toepferei.example', telefon: '', ustId: '' },
|
||||
}
|
||||
const TEXTE = {
|
||||
agb: 'Unsere AGB.',
|
||||
widerrufsbelehrung: 'Unsere Widerrufsbelehrung.',
|
||||
widerrufsformular: 'Unser Widerrufsformular.',
|
||||
impressumUrl: 'https://toepferei.example/impressum',
|
||||
datenschutzUrl: 'https://toepferei.example/datenschutz',
|
||||
}
|
||||
const ARTIKEL = { name: 'Tasse', preisCent: 1800 }
|
||||
|
||||
const q = (projectId = 'pt1') => `projectId=${projectId}`
|
||||
const lesen = (pfad, projectId = 'pt1', opts = { cookie }) => send('GET', `/api/shop/${pfad}${pfad.includes('?') ? '&' : '?'}${q(projectId)}`, opts)
|
||||
const schreiben = (method, pfad, body = {}, opts = { cookie }) => send(method, `/api/shop/${pfad}`, { ...opts, body: { projectId: 'pt1', ...body } })
|
||||
|
||||
async function aktiviere(projectId = 'pt1', state = 'active') {
|
||||
const feature = await sf.ensureFeatureDoc('c1', projectId, 'shop')
|
||||
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields(state))
|
||||
return sf.getFeatureDoc('c1', projectId, 'shop')
|
||||
}
|
||||
const shopConfigDoc = (feature) => aw.docs('shopConfig').get(feature.$id)
|
||||
const dateien = async (siteKey) => (await readdir(path.join(process.env.SHOP_UPLOAD_DIR, siteKey)).catch(() => [])).sort()
|
||||
|
||||
async function png(w = 64, h = 48) {
|
||||
return sharp({ create: { width: w, height: h, channels: 3, background: '#c63' } }).png().toBuffer()
|
||||
}
|
||||
async function lade(buffer, { projectId = 'pt1', opts = { cookie }, type = 'application/octet-stream' } = {}) {
|
||||
const res = await fetch(`${app.url}/api/shop/bild?projectId=${projectId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': type, ...(opts.cookie ? { cookie: opts.cookie } : {}), ...(opts.headers || {}) },
|
||||
body: buffer,
|
||||
})
|
||||
const text = await res.text()
|
||||
let body = text
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
/* Text */
|
||||
}
|
||||
return { status: res.status, body }
|
||||
}
|
||||
|
||||
/** Shop bis "bereit zum Oeffnen": active, Einstellungen, Rechtstexte bestaetigt, Stripe bereit, ein Artikel. */
|
||||
async function machBereit({ mitArtikel = true } = {}) {
|
||||
const feature = await aktiviere()
|
||||
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 200)
|
||||
if (mitArtikel) assert.equal((await schreiben('POST', 'produkte', { produkt: ARTIKEL })).status, 201)
|
||||
assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE })).status, 200)
|
||||
const hash = (await lesen('rechtstexte')).body.hash
|
||||
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash })).status, 200)
|
||||
await updateDocument(config.collections.shopConfig, feature.$id, {
|
||||
stripeAccountId: 'acct_test1',
|
||||
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true, details_submitted: true, currently_due: [], past_due: [], disabled_reason: null, geheim: 'nie', geprueftAt: '2026-10-05T10:00:00.000Z' }),
|
||||
})
|
||||
return feature
|
||||
}
|
||||
|
||||
let origin
|
||||
beforeEach(async () => {
|
||||
origin ??= { mode: config.shop.mode, pilotEmails: config.shop.pilotEmails }
|
||||
config.shop.mode = 'pilot'
|
||||
config.shop.pilotEmails = [customers.c1.email]
|
||||
resetShopRoutesForTests()
|
||||
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', '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 })
|
||||
const { leereCache } = await import('../server/services/shop/kontext.js')
|
||||
leereCache()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Zugang
|
||||
|
||||
test('Zugang: ohne Session 401, ohne Projekt 400, fremdes oder unbekanntes Projekt 403, Schalter aus 409, Funktion nicht eingerichtet 409', async () => {
|
||||
await aktiviere()
|
||||
assert.equal((await send('GET', '/api/shop/einstellungen?projectId=pt1')).status, 401)
|
||||
assert.equal((await send('GET', '/api/shop/einstellungen', { cookie })).status, 400)
|
||||
assert.equal((await lesen('einstellungen', 'pt3')).status, 403)
|
||||
assert.equal((await lesen('einstellungen', 'gibtsnicht')).status, 403)
|
||||
for (const pfad of ['einstellungen', 'produkte', 'rechtstexte']) assert.equal((await lesen(pfad, 'pt2')).status, 409, `${pfad}: pt2 hat keine Funktion`)
|
||||
const keine = await lesen('einstellungen', 'pt2')
|
||||
assert.deepEqual([keine.body.code, keine.body.error], ['nicht_eingerichtet', 'Der Webshop ist für dieses Projekt noch nicht eingerichtet.'])
|
||||
config.shop.mode = 'off'
|
||||
const aus = await lesen('einstellungen')
|
||||
assert.deepEqual([aus.status, aus.body.code], [409, 'nicht_verfuegbar'])
|
||||
config.shop.mode = 'pilot'
|
||||
config.shop.pilotEmails = ['jemand@example.invalid']
|
||||
assert.equal((await lesen('produkte')).status, 409)
|
||||
})
|
||||
|
||||
test('Zustaende: ordered und off => 409; disabled nur lesend; setup legt shopConfig an (ID = portalFeatures-ID, siteKey kopiert)', async () => {
|
||||
const feature = await aktiviere('pt1', 'ordered')
|
||||
assert.equal((await lesen('einstellungen')).status, 409)
|
||||
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('setup'))
|
||||
assert.equal(aw.docs('shopConfig').size, 0)
|
||||
const setup = await lesen('einstellungen')
|
||||
assert.equal(setup.status, 200)
|
||||
assert.equal(setup.body.einstellungen.lieferzeit, '2–4 Werktage')
|
||||
assert.equal(setup.body.vollstaendig, false)
|
||||
const angelegt = shopConfigDoc(feature)
|
||||
assert.deepEqual([angelegt.customerId, angelegt.projectId, angelegt.siteKey], ['c1', 'pt1', feature.siteKey])
|
||||
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 200, 'setup darf schreiben')
|
||||
|
||||
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('disabled'))
|
||||
for (const pfad of ['einstellungen', 'produkte', 'rechtstexte']) assert.equal((await lesen(pfad)).status, 200, `${pfad} lesbar`)
|
||||
for (const [m, pfad, body] of [
|
||||
['PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }], ['POST', 'produkte', { produkt: ARTIKEL }], ['PUT', 'rechtstexte', { rechtstexte: TEXTE }],
|
||||
['POST', 'oeffnen', { offen: false }], ['POST', 'produkte/sortierung', { ids: [] }],
|
||||
]) {
|
||||
const res = await schreiben(m, pfad, body)
|
||||
assert.deepEqual([res.status, res.body.code], [409, 'nicht_eingerichtet'], `${m} ${pfad}`)
|
||||
}
|
||||
assert.equal((await lesen('einstellungen', 'pt4')).status, 409)
|
||||
})
|
||||
|
||||
test('archiviertes Projekt: Lesen ja (falls Funktion da), Schreiben 409 wie bei Bestellungen', async () => {
|
||||
const feature = await sf.ensureFeatureDoc('c1', 'pt4', 'shop')
|
||||
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('active'))
|
||||
assert.equal((await lesen('einstellungen', 'pt4')).status, 200)
|
||||
const res = await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN, projectId: 'pt4' })
|
||||
assert.equal(res.status, 409)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Status
|
||||
|
||||
test('GET /status ohne Funktion: gleiche Form mit leeren Werten, legt nichts an', async () => {
|
||||
const res = await lesen('status')
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(res.body, {
|
||||
verfuegbar: true, state: 'off', ticketWoid: null, anfrage: null, bereitschaft: null, geoeffnet: false, schalter: false, siteKey: null,
|
||||
snippet: null, vorschauUrl: null, stripe: { verbunden: false, status: null }, zahlen: null,
|
||||
})
|
||||
assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0)
|
||||
assert.equal((await lesen('status', 'pt3')).status, 403)
|
||||
})
|
||||
|
||||
test('GET /status mit Shop: Bereitschaft, Snippet, Vorschau, Stripe ohne Konto-ID und fremde Felder, Zahlen; geoeffnet erst nach Oeffnen', async () => {
|
||||
const feature = await machBereit()
|
||||
const res = await lesen('status')
|
||||
assert.equal(res.status, 200)
|
||||
const b = res.body
|
||||
assert.deepEqual([b.verfuegbar, b.state, b.siteKey, b.schalter, b.geoeffnet], [true, 'active', feature.siteKey, false, false])
|
||||
assert.equal(b.bereitschaft.bereit, true)
|
||||
assert.deepEqual(b.bereitschaft.punkte.map((p) => [p.id, p.ok]), [['einbau', true], ['stripe', true], ['einstellungen', true], ['rechtstexte', true], ['artikel', true]])
|
||||
assert.equal(b.snippet, `<div data-webklar-shop="${feature.siteKey}"></div>\n<script src="${config.publicBaseUrl}/embed/shop.js" defer></script>`)
|
||||
assert.equal(b.vorschauUrl, `${config.publicBaseUrl}/shop/${feature.siteKey}`)
|
||||
assert.deepEqual(b.stripe, {
|
||||
verbunden: true,
|
||||
status: { charges_enabled: true, payouts_enabled: true, details_submitted: true, currently_due: [], past_due: [], disabled_reason: null, geprueftAt: '2026-10-05T10:00:00.000Z' },
|
||||
})
|
||||
assert.ok(!JSON.stringify(b).includes('acct_test1') && !JSON.stringify(b).includes('geheim'))
|
||||
assert.deepEqual(b.zahlen, { artikel: 1, aktiveArtikel: 1, offeneBestellungen: 0, bestellungen30: 0, umsatz30Cent: 0 })
|
||||
assert.equal(b.ticketWoid, null)
|
||||
|
||||
assert.equal((await schreiben('POST', 'oeffnen', { offen: true })).status, 200)
|
||||
assert.equal((await lesen('status')).body.geoeffnet, true)
|
||||
assert.equal((await lesen('status')).body.schalter, true)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Einstellungen
|
||||
|
||||
test('Einstellungen: speichern, Version zaehlt, veraltete Version 409, Fehler je Feld 400', async () => {
|
||||
await aktiviere()
|
||||
const leer = await lesen('einstellungen')
|
||||
assert.equal(leer.body.version, 0)
|
||||
const gut = await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN, version: 0 })
|
||||
assert.equal(gut.status, 200)
|
||||
assert.deepEqual([gut.body.version, gut.body.vollstaendig, gut.body.einstellungen.shopname], [1, true, 'Töpferei Keramik'])
|
||||
assert.equal((await lesen('einstellungen')).body.einstellungen.anbieter.ort, 'Kaiserslautern')
|
||||
|
||||
const veraltet = await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN, version: 0 })
|
||||
assert.deepEqual([veraltet.status, veraltet.body.code], [409, 'EINSTELLUNG_VERALTET'])
|
||||
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 200, 'ohne Version keine Pruefung')
|
||||
|
||||
const schlecht = await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, akzent: 'rot' } })
|
||||
assert.equal(schlecht.status, 400)
|
||||
assert.ok(schlecht.body.fehler && Object.keys(schlecht.body.fehler).length > 0)
|
||||
assert.equal(schlecht.body.error, Object.values(schlecht.body.fehler)[0])
|
||||
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: 'text' })).status, 400)
|
||||
})
|
||||
|
||||
test('Einstellungen speichern leert den 30-s-Speicher von shopPerSiteKey', async () => {
|
||||
const feature = await aktiviere()
|
||||
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
|
||||
const vorher = await shopPerSiteKey(feature.siteKey)
|
||||
assert.equal(vorher.shop.einstellungen.shopname, 'Töpferei Keramik')
|
||||
await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, shopname: 'Neu' } })
|
||||
assert.equal((await shopPerSiteKey(feature.siteKey)).shop.einstellungen.shopname, 'Neu')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Artikel
|
||||
|
||||
test('Artikel: anlegen 201, lesen, aendern, Fehler 400 je Feld, unbekannte ID 404, Kleinunternehmer-Steuer', async () => {
|
||||
await aktiviere()
|
||||
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }) // Kleinunternehmer => 0 %
|
||||
const neu = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, kurz: 'Handgemacht', extra: 'ignoriert' } })
|
||||
assert.equal(neu.status, 201)
|
||||
const id = neu.body.produkt.id
|
||||
assert.deepEqual([neu.body.produkt.name, neu.body.produkt.preisCent, neu.body.produkt.steuersatz, neu.body.produkt.aktiv, neu.body.produkt.sort], ['Tasse', 1800, 0, true, 0])
|
||||
assert.equal(neu.body.produkt.extra, undefined)
|
||||
|
||||
const liste = await lesen('produkte')
|
||||
assert.equal(liste.status, 200)
|
||||
assert.deepEqual(liste.body.produkte.map((p) => p.id), [id])
|
||||
assert.deepEqual([liste.body.maxArtikel, liste.body.maxBilder], [config.shop.maxArtikel, config.shop.maxBilder])
|
||||
|
||||
const geaendert = await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, name: 'Große Tasse', aktiv: false } })
|
||||
assert.equal(geaendert.status, 200)
|
||||
assert.deepEqual([geaendert.body.produkt.id, geaendert.body.produkt.name, geaendert.body.produkt.aktiv], [id, 'Große Tasse', false])
|
||||
assert.equal((await lesen('produkte')).body.produkte[0].aktiv, false)
|
||||
assert.equal(aw.docs('shopProducts').size, 1)
|
||||
|
||||
const schlecht = await schreiben('POST', 'produkte', { produkt: { name: '', preisCent: -5 } })
|
||||
assert.equal(schlecht.status, 400)
|
||||
assert.ok(schlecht.body.fehler.name && schlecht.body.fehler.preisCent)
|
||||
assert.equal((await schreiben('POST', 'produkte', { produkt: 'text' })).status, 400)
|
||||
assert.equal((await schreiben('POST', 'produkte', {})).status, 400)
|
||||
assert.equal((await schreiben('PUT', 'produkte/gibtsnicht', { produkt: ARTIKEL })).status, 404)
|
||||
assert.equal((await schreiben('DELETE', 'produkte/gibtsnicht')).status, 404)
|
||||
assert.equal(aw.docs('shopProducts').size, 1)
|
||||
})
|
||||
|
||||
test('Artikel mit Regelbesteuerung: Steuersatz kommt aus den gespeicherten Einstellungen', async () => {
|
||||
await aktiviere()
|
||||
await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, kleinunternehmer: false } })
|
||||
const res = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, steuersatz: 7 } })
|
||||
assert.equal(res.body.produkt.steuersatz, 7)
|
||||
})
|
||||
|
||||
test('Artikel gehoeren zu ihrem Shop: fremder Shop kann sie weder sehen noch aendern', async () => {
|
||||
await aktiviere()
|
||||
const neu = await schreiben('POST', 'produkte', { produkt: ARTIKEL })
|
||||
await aktiviere('pt2')
|
||||
assert.deepEqual((await lesen('produkte', 'pt2')).body.produkte, [])
|
||||
assert.equal((await schreiben('PUT', `produkte/${neu.body.produkt.id}`, { produkt: ARTIKEL, projectId: 'pt2' })).status, 404)
|
||||
assert.equal((await schreiben('DELETE', `produkte/${neu.body.produkt.id}`, {}, { cookie }) ).status, 200)
|
||||
})
|
||||
|
||||
test('Sortierung: neue Reihenfolge, falsche Liste 400', async () => {
|
||||
await aktiviere()
|
||||
const ids = []
|
||||
for (const name of ['A', 'B', 'C']) ids.push((await schreiben('POST', 'produkte', { produkt: { name, preisCent: 100 } })).body.produkt.id)
|
||||
const res = await schreiben('POST', 'produkte/sortierung', { ids: [ids[2], ids[0], ids[1]] })
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(res.body.produkte.map((p) => p.name), ['C', 'A', 'B'])
|
||||
assert.deepEqual((await lesen('produkte')).body.produkte.map((p) => p.name), ['C', 'A', 'B'])
|
||||
assert.equal((await schreiben('POST', 'produkte/sortierung', { ids: [ids[0]] })).status, 400)
|
||||
assert.equal((await schreiben('POST', 'produkte/sortierung', {})).status, 400)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Bilder
|
||||
|
||||
test('Bild hochladen: 201, Sidecar mit Shop und Kunde, Vorschau lesbar; Typfehler 415, zu gross 413, ohne Session 401, fremdes Projekt 403', async () => {
|
||||
const feature = await aktiviere()
|
||||
const res = await lade(await png(2000, 1000))
|
||||
assert.equal(res.status, 201)
|
||||
assert.deepEqual([res.body.w, res.body.h], [1600, 800])
|
||||
assert.match(res.body.imageId, /^[a-z0-9-]{36}$/)
|
||||
const dateiListe = await dateien(feature.siteKey)
|
||||
assert.deepEqual(dateiListe, [`${res.body.imageId}-l.webp`, `${res.body.imageId}-s.webp`, `${res.body.imageId}.json`].sort())
|
||||
const meta = JSON.parse(await (await import('node:fs/promises')).readFile(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${res.body.imageId}.json`), 'utf8'))
|
||||
assert.deepEqual([meta.customerId, meta.shopId, meta.claimed], ['c1', feature.$id, false])
|
||||
|
||||
for (const datei of [`${res.body.imageId}-s.webp`, `${res.body.imageId}-l.webp`]) {
|
||||
const bild = await fetch(`${app.url}/api/shop/bild/pt1/${datei}`, { headers: { cookie } })
|
||||
assert.equal(bild.status, 200)
|
||||
assert.equal(bild.headers.get('content-type'), 'image/webp')
|
||||
assert.equal(bild.headers.get('x-content-type-options'), 'nosniff')
|
||||
assert.equal(bild.headers.get('cache-control'), 'private, max-age=3600')
|
||||
assert.equal((await sharp(Buffer.from(await bild.arrayBuffer())).metadata()).format, 'webp')
|
||||
}
|
||||
|
||||
assert.equal((await lade(Buffer.from('%PDF-1.4 kein Bild'))).status, 415)
|
||||
assert.equal((await lade(await png(), { type: 'text/plain' })).status, 415, 'kein octet-stream')
|
||||
const zuGross = await lade(Buffer.alloc(8 * 1024 * 1024 + 1))
|
||||
assert.deepEqual([zuGross.status, zuGross.body.error], [413, 'Das Bild ist größer als 8 MB.'])
|
||||
assert.equal((await lade(await png(), { opts: {} })).status, 401)
|
||||
assert.equal((await lade(await png(), { projectId: 'pt3' })).status, 403)
|
||||
assert.equal((await lade(await png(), { projectId: 'pt2' })).status, 409, 'pt2 hat keine Funktion')
|
||||
assert.equal((await dateien(feature.siteKey)).length, 3, 'nichts Weiteres abgelegt')
|
||||
})
|
||||
|
||||
test('Bild-Vorschau: nur eigene Projekte, nur gueltige Dateinamen, nur vorhandene Bilder', async () => {
|
||||
const feature = await aktiviere()
|
||||
const { body } = await lade(await png())
|
||||
const url = (projekt, datei) => `${app.url}/api/shop/bild/${projekt}/${datei}`
|
||||
const hole = (projekt, datei, opts = { cookie }) => fetch(url(projekt, datei), { headers: opts.cookie ? { cookie: opts.cookie } : {} })
|
||||
assert.equal((await hole('pt1', `${body.imageId}-s.webp`, {})).status, 401)
|
||||
assert.equal((await hole('pt3', `${body.imageId}-s.webp`)).status, 403)
|
||||
assert.equal((await hole('pt1', `${body.imageId}-xl.webp`)).status, 404)
|
||||
assert.equal((await hole('pt1', `${body.imageId}.json`)).status, 404, 'Sidecar wird nicht ausgeliefert')
|
||||
assert.equal((await hole('pt1', '..%2F..%2Fetc%2Fpasswd')).status, 404)
|
||||
assert.equal((await hole('pt1', 'gibtsnicht-s.webp')).status, 404)
|
||||
assert.equal((await hole('pt1', `${body.imageId}-s.webp`, { cookie: customerCookie('c2') })).status, 403)
|
||||
// Datei ohne Sidecar gilt als nicht vorhanden
|
||||
await rm(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${body.imageId}.json`))
|
||||
assert.equal((await hole('pt1', `${body.imageId}-s.webp`)).status, 404)
|
||||
})
|
||||
|
||||
test('Bilder: 40 Uploads je Stunde, der 41. wird mit 429 abgelehnt', async () => {
|
||||
await aktiviere()
|
||||
const klein = await png(8, 8)
|
||||
for (let i = 0; i < 40; i++) assert.equal((await lade(klein)).status, 201, `Upload ${i + 1}`)
|
||||
const zuViele = await lade(klein)
|
||||
assert.deepEqual([zuViele.status, zuViele.body.error], [429, 'Zu viele Bilder in kurzer Zeit. Bitte versuch es später wieder.'])
|
||||
assert.equal((await lade(klein, { opts: { cookie: customerCookie('c1') } })).status, 429)
|
||||
})
|
||||
|
||||
test('Artikel mit Bildern: unbekannte oder fremde ID 400, Beanspruchen, Entfernen loescht, geteiltes Bild bleibt bis zuletzt, Loeschen des Artikels raeumt auf', async () => {
|
||||
const feature = await aktiviere()
|
||||
const a = (await lade(await png())).body.imageId
|
||||
const b = (await lade(await png())).body.imageId
|
||||
const c = (await lade(await png())).body.imageId
|
||||
const meta = async (id) => JSON.parse(await (await import('node:fs/promises')).readFile(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${id}.json`), 'utf8'))
|
||||
const da = (id) => existsSync(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${id}-l.webp`))
|
||||
|
||||
const unbekannt = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: [a, 'gibtsnicht'] } })
|
||||
assert.equal(unbekannt.status, 400)
|
||||
assert.ok(unbekannt.body.fehler.bilder)
|
||||
assert.equal(aw.docs('shopProducts').size, 0)
|
||||
assert.equal((await meta(a)).claimed, false, 'nichts beansprucht, wenn der Artikel nicht gespeichert wurde')
|
||||
|
||||
// Bild eines anderen Shops
|
||||
const f2 = await aktiviere('pt2')
|
||||
const fremd = (await lade(await png(), { projectId: 'pt2' })).body.imageId
|
||||
const mitFremd = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: [fremd] } })
|
||||
assert.equal(mitFremd.status, 400)
|
||||
assert.ok(existsSync(path.join(process.env.SHOP_UPLOAD_DIR, f2.siteKey, `${fremd}-l.webp`)))
|
||||
const mitPfad = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: ['../x'] } })
|
||||
assert.equal(mitPfad.status, 400)
|
||||
|
||||
const p1 = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: [a, b] } })
|
||||
assert.equal(p1.status, 201)
|
||||
assert.deepEqual(p1.body.produkt.bilder, [a, b])
|
||||
assert.deepEqual(p1.body.produkt.bildUrls[0], { id: a, klein: `/api/shop/bild/pt1/${a}-s.webp`, gross: `/api/shop/bild/pt1/${a}-l.webp` })
|
||||
assert.equal((await meta(a)).claimed, true)
|
||||
assert.equal((await meta(b)).claimed, true)
|
||||
assert.equal((await meta(c)).claimed, false)
|
||||
const p2 = await schreiben('POST', 'produkte', { produkt: { name: 'Zweiter', preisCent: 500, bilder: [b] } })
|
||||
assert.equal(p2.status, 201)
|
||||
|
||||
// a entfernen => weg; b entfernen aus p1, aber p2 nutzt es noch => bleibt
|
||||
const ohne = await schreiben('PUT', `produkte/${p1.body.produkt.id}`, { produkt: { ...ARTIKEL, bilder: [] } })
|
||||
assert.equal(ohne.status, 200)
|
||||
assert.equal(da(a), false)
|
||||
assert.equal(existsSync(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${a}.json`)), false)
|
||||
assert.equal(da(b), true, 'p2 nutzt b noch')
|
||||
assert.equal(da(c), true, 'unbenutzt hochgeladene Bilder raeumt nur der Nachtjob auf')
|
||||
|
||||
// p2 loeschen => b weg
|
||||
assert.equal((await schreiben('DELETE', `produkte/${p2.body.produkt.id}`)).status, 200)
|
||||
assert.equal(da(b), false)
|
||||
assert.equal(aw.docs('shopProducts').size, 1)
|
||||
|
||||
// Reihenfolge und Anzahl der Bilder bleiben, mehr als maxBilder abgelehnt
|
||||
const viele = []
|
||||
for (let i = 0; i < config.shop.maxBilder + 1; i++) viele.push((await lade(await png(8, 8))).body.imageId)
|
||||
const zuViele = await schreiben('PUT', `produkte/${p1.body.produkt.id}`, { produkt: { ...ARTIKEL, bilder: viele } })
|
||||
assert.equal(zuViele.status, 400)
|
||||
assert.ok(zuViele.body.fehler.bilder)
|
||||
const ok = await schreiben('PUT', `produkte/${p1.body.produkt.id}`, { produkt: { ...ARTIKEL, bilder: viele.slice(0, 2).reverse() } })
|
||||
assert.deepEqual(ok.body.produkt.bilder, viele.slice(0, 2).reverse())
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Rechtstexte
|
||||
|
||||
test('Rechtstexte GET: Entwurf leer, nichts bestaetigt, Muster, erzeugte Texte, Gewaehrleistung, Hash, Bestaetigungstext', async () => {
|
||||
const feature = await aktiviere()
|
||||
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
|
||||
const res = await lesen('rechtstexte')
|
||||
assert.equal(res.status, 200)
|
||||
const b = res.body
|
||||
assert.deepEqual(Object.keys(b).sort(), ['bestaetigt', 'bestaetigungText', 'entwurf', 'gewaehrleistung', 'hash', 'muster', 'versandUndZahlung', 'vollstaendig'])
|
||||
assert.deepEqual(b.entwurf, { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' })
|
||||
assert.equal(b.bestaetigt, null)
|
||||
assert.equal(b.vollstaendig, false)
|
||||
assert.match(b.hash, /^[0-9a-f]{64}$/)
|
||||
assert.equal(b.hash, rechtstexteHash(b.entwurf, (await lesen('einstellungen')).body.einstellungen))
|
||||
for (const k of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) assert.ok(b.muster[k].length > 100, k)
|
||||
assert.ok(b.muster.agb.includes('Töpferei Keramik'))
|
||||
assert.ok(b.muster.widerrufsbelehrung.includes(`${config.publicBaseUrl}/shop/${feature.siteKey}/widerruf`))
|
||||
assert.ok(b.versandUndZahlung.includes('Versand'))
|
||||
assert.deepEqual([b.gewaehrleistung.bild, b.gewaehrleistung.text.startsWith('GESETZLICHE GEWÄHRLEISTUNG')], ['/img/gewaehrleistung-eu.webp', true])
|
||||
assert.match(b.bestaetigungText, /^Ich habe die Texte geprüft/)
|
||||
})
|
||||
|
||||
test('Rechtstexte PUT: speichert den Entwurf, normalisiert, Fehler 400; Hash aendert sich mit dem Text', async () => {
|
||||
const feature = await aktiviere()
|
||||
const leer = (await lesen('rechtstexte')).body.hash
|
||||
const res = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, agb: ' Neue AGB \r\n\r\n\r\n\r\nZeile', extra: 'x' } })
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.body.entwurf.agb, 'Neue AGB\n\nZeile')
|
||||
assert.equal(res.body.vollstaendig, true)
|
||||
assert.notEqual(res.body.hash, leer)
|
||||
assert.equal((await lesen('rechtstexte')).body.entwurf.widerrufsformular, 'Unser Widerrufsformular.')
|
||||
assert.equal(JSON.parse(shopConfigDoc(feature).rechtstexte).bestaetigt, null)
|
||||
|
||||
const schlecht = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, impressumUrl: 'http://unsicher.example', datenschutzUrl: 'javascript:alert(1)' } })
|
||||
assert.equal(schlecht.status, 400)
|
||||
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) } })
|
||||
assert.equal(zuLang.status, 400)
|
||||
assert.equal((await lesen('rechtstexte')).body.entwurf.agb, 'Neue AGB\n\nZeile', 'Fehler aendert nichts')
|
||||
})
|
||||
|
||||
test('Rechtstexte bestaetigen: falscher oder alter Hash 409, unvollstaendig 400, sonst Kopie mit Zeit, Person und Hash', async () => {
|
||||
const feature = await aktiviere()
|
||||
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
|
||||
const unvollstaendig = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, datenschutzUrl: '' } })
|
||||
const hashUnvollstaendig = unvollstaendig.body.hash
|
||||
const leer = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: hashUnvollstaendig })
|
||||
assert.equal(leer.status, 400)
|
||||
assert.equal(shopConfigDoc(feature).rechtstexteHash || '', '')
|
||||
|
||||
const voll = await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE })
|
||||
const falsch = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: 'abc' })
|
||||
assert.deepEqual([falsch.status, falsch.body.code], [409, 'texte_geaendert'])
|
||||
assert.match(falsch.body.error, /^Die Texte wurden inzwischen geändert/)
|
||||
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', {})).status, 409)
|
||||
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: hashUnvollstaendig })).status, 409, 'Hash des frueheren Entwurfs')
|
||||
|
||||
const ok = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: voll.body.hash })
|
||||
assert.equal(ok.status, 200)
|
||||
assert.deepEqual(ok.body.bestaetigt.texte, TEXTE)
|
||||
assert.equal(ok.body.bestaetigt.von, `${customers.c1.name} <${customers.c1.email}>`)
|
||||
assert.equal(ok.body.bestaetigt.aktuell, true)
|
||||
assert.ok(Math.abs(Date.now() - Date.parse(ok.body.bestaetigt.at)) < 10_000)
|
||||
const doc = shopConfigDoc(feature)
|
||||
assert.equal(doc.rechtstexteHash, voll.body.hash)
|
||||
assert.equal(doc.rechtstexteBestaetigtVon, `${customers.c1.name} <${customers.c1.email}>`)
|
||||
assert.equal(new Date(doc.rechtstexteBestaetigtAt).toISOString(), new Date(ok.body.bestaetigt.at).toISOString())
|
||||
const gespeichert = JSON.parse(doc.rechtstexte)
|
||||
assert.deepEqual(gespeichert.entwurf, TEXTE)
|
||||
assert.deepEqual([gespeichert.bestaetigt.texte, gespeichert.bestaetigt.hash], [TEXTE, voll.body.hash])
|
||||
assert.equal((await shopPerSiteKey(feature.siteKey)).shop.rechtstexte.bestaetigt.hash, voll.body.hash, 'Speicher wurde geleert')
|
||||
|
||||
// Entwurf aendern: die bestaetigte Fassung bleibt, ist aber nicht mehr aktuell; Bereitschaft verlangt neue Bestaetigung
|
||||
const geaendert = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, agb: 'Geaenderte AGB' } })
|
||||
assert.deepEqual(geaendert.body.bestaetigt.texte, TEXTE)
|
||||
assert.equal(geaendert.body.bestaetigt.aktuell, false)
|
||||
assert.equal(geaendert.body.entwurf.agb, 'Geaenderte AGB')
|
||||
const stand = (await lesen('status')).body.bereitschaft.punkte.find((p) => p.id === 'rechtstexte')
|
||||
assert.equal(stand.ok, false)
|
||||
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: voll.body.hash })).status, 409, 'alter Hash')
|
||||
})
|
||||
|
||||
test('Rechtstexte: Hash haengt auch an den Einstellungen (Versand und Zahlung), eine Aenderung dort verlangt neue Bestaetigung', async () => {
|
||||
await aktiviere()
|
||||
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
|
||||
await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE })
|
||||
const alt = (await lesen('rechtstexte')).body.hash
|
||||
await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, versand: { ...EINSTELLUNGEN.versand, kostenCent: { DE: 690 } } } })
|
||||
const neu = (await lesen('rechtstexte')).body.hash
|
||||
assert.notEqual(alt, neu)
|
||||
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: alt })).status, 409)
|
||||
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: neu })).status, 200)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Oeffnen
|
||||
|
||||
test('Oeffnen: nur wenn bereit (sonst 409 mit Punkten), Schliessen immer, Speicher geleert, Eingabe geprueft', async () => {
|
||||
const feature = await machBereit({ mitArtikel: false })
|
||||
const nicht = await schreiben('POST', 'oeffnen', { offen: true })
|
||||
assert.deepEqual([nicht.status, nicht.body.code], [409, 'nicht_bereit'])
|
||||
assert.deepEqual(nicht.body.punkte.filter((p) => !p.ok).map((p) => p.id), ['artikel'])
|
||||
assert.ok(nicht.body.punkte.every((p) => typeof p.text === 'string'))
|
||||
assert.equal(shopConfigDoc(feature).geoeffnet !== true, true)
|
||||
assert.equal((await schreiben('POST', 'oeffnen', { offen: 'ja' })).status, 400)
|
||||
assert.equal((await schreiben('POST', 'oeffnen', {})).status, 400)
|
||||
|
||||
await schreiben('POST', 'produkte', { produkt: ARTIKEL })
|
||||
const vorher = await shopPerSiteKey(feature.siteKey)
|
||||
assert.equal(vorher.shop.geoeffnet, false)
|
||||
const auf = await schreiben('POST', 'oeffnen', { offen: true })
|
||||
assert.equal(auf.status, 200)
|
||||
assert.deepEqual([auf.body.schalter, auf.body.geoeffnet, auf.body.bereitschaft.bereit], [true, true, true])
|
||||
assert.equal(shopConfigDoc(feature).geoeffnet, true)
|
||||
assert.match(shopConfigDoc(feature).geoeffnetAt, /^\d{4}-\d\d-\d\d/)
|
||||
assert.equal((await shopPerSiteKey(feature.siteKey)).shop.geoeffnet, true, 'Speicher geleert')
|
||||
|
||||
const zu = await schreiben('POST', 'oeffnen', { offen: false })
|
||||
assert.deepEqual([zu.status, zu.body.schalter, zu.body.geoeffnet], [200, false, false])
|
||||
assert.equal((await shopPerSiteKey(feature.siteKey)).shop.geoeffnet, false)
|
||||
|
||||
// Stripe faellt aus: Oeffnen scheitert, Schliessen geht trotzdem
|
||||
await updateDocument(config.collections.shopConfig, feature.$id, { stripeStatus: JSON.stringify({ charges_enabled: false }) })
|
||||
const stripeAus = await schreiben('POST', 'oeffnen', { offen: true })
|
||||
assert.deepEqual([stripeAus.status, stripeAus.body.punkte.filter((p) => !p.ok).map((p) => p.id)], [409, ['stripe']])
|
||||
assert.equal((await schreiben('POST', 'oeffnen', { offen: false })).status, 200)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Kundenansicht
|
||||
|
||||
test('Kundenansicht: Einstellungen, Artikel, Rechtstexte-Entwurf und Bilder erlaubt und protokolliert; Lesen ebenfalls', async () => {
|
||||
const feature = await aktiviere()
|
||||
assert.equal((await lesen('einstellungen', 'pt1', KV)).status, 200)
|
||||
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }, KV)).status, 200)
|
||||
const neu = await schreiben('POST', 'produkte', { produkt: ARTIKEL }, KV)
|
||||
assert.equal(neu.status, 201)
|
||||
const id = neu.body.produkt.id
|
||||
assert.equal((await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, name: 'Kanne' } }, KV)).status, 200)
|
||||
assert.equal((await schreiben('POST', 'produkte/sortierung', { ids: [id] }, KV)).status, 200)
|
||||
assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE }, KV)).status, 200)
|
||||
const bild = await lade(await png(), { opts: KV })
|
||||
assert.equal(bild.status, 201)
|
||||
assert.equal((await schreiben('DELETE', `produkte/${id}`, {}, KV)).status, 200)
|
||||
assert.equal((await dateien(feature.siteKey)).length, 3, 'das hochgeladene Bild gehoert keinem Artikel und wartet auf den Nachtjob')
|
||||
|
||||
const eintraege = [...aw.docs('customerViewLog').values()]
|
||||
assert.ok(eintraege.every((e) => e.action === 'shop_aenderung' && e.staffName === 'Andrej' && e.customerId === 'c1'))
|
||||
assert.deepEqual(eintraege.map((e) => e.detail).sort(), [
|
||||
'Artikel angelegt: Tasse', 'Artikel geändert: Kanne', 'Artikel gelöscht: Kanne', 'Artikel sortiert', 'Bild hochgeladen',
|
||||
'Einstellungen gespeichert', 'Rechtstexte-Entwurf gespeichert',
|
||||
].sort())
|
||||
})
|
||||
|
||||
test('Kundenansicht: Anfrage, Oeffnen und Bestaetigen sind gesperrt (Waechter), Stripe-Pfade auch', async () => {
|
||||
await machBereit()
|
||||
const hash = (await lesen('rechtstexte')).body.hash
|
||||
for (const [pfad, body] of [['oeffnen', { offen: true }], ['rechtstexte/bestaetigen', { hash }], ['anfrage', {}], ['stripe/verbinden', {}]]) {
|
||||
const res = await schreiben('POST', pfad, body, KV)
|
||||
assert.deepEqual([res.status, res.body.code], [403, 'view_blocked'], pfad)
|
||||
}
|
||||
assert.equal(shopConfigDoc(await sf.getFeatureDoc('c1', 'pt1', 'shop')).geoeffnet !== true, true)
|
||||
})
|
||||
|
||||
test('Kundenansicht: der Router sperrt Oeffnen und Bestaetigen selbst, auch ohne Waechter (alte Ansicht und Kundenansicht)', async () => {
|
||||
await machBereit()
|
||||
const hash = (await lesen('rechtstexte')).body.hash
|
||||
const express = (await import('express')).default
|
||||
const { sessionMiddleware } = await import('../server/middleware/session.js')
|
||||
const roh = express()
|
||||
roh.use(sessionMiddleware())
|
||||
roh.use(express.json())
|
||||
roh.use('/api/shop', shopRoutes)
|
||||
const server = await new Promise((resolve) => {
|
||||
const s = roh.listen(0, '127.0.0.1', () => resolve(s))
|
||||
})
|
||||
try {
|
||||
const url = `http://127.0.0.1:${server.address().port}`
|
||||
for (const [pfad, body] of [['/api/shop/oeffnen', { projectId: 'pt1', offen: true }], ['/api/shop/rechtstexte/bestaetigen', { projectId: 'pt1', hash }]]) {
|
||||
for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) {
|
||||
const res = await fetch(url + pfad, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify(body) })
|
||||
assert.equal(res.status, 403, pfad)
|
||||
assert.equal((await res.json()).error, 'In der Kundenansicht kann das nicht geändert werden.')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
assert.equal(JSON.parse(shopConfigDoc(await sf.getFeatureDoc('c1', 'pt1', 'shop')).rechtstexte).bestaetigt.hash, hash, 'unveraendert')
|
||||
assert.equal(shopConfigDoc(await sf.getFeatureDoc('c1', 'pt1', 'shop')).geoeffnet !== true, true)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Rate-Limit und Fehler
|
||||
|
||||
test('Schreib-Limit: 300 je Stunde und Kunde, danach 429; Lesen zaehlt nicht; anderer Kunde unberuehrt', async () => {
|
||||
await aktiviere()
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const res = await send('POST', '/api/shop/oeffnen', { cookie, body: { projectId: 'pt1' } })
|
||||
assert.equal(res.status, 400, `Aufruf ${i + 1}`)
|
||||
}
|
||||
const zuViele = await send('POST', '/api/shop/oeffnen', { cookie, body: { projectId: 'pt1', offen: false } })
|
||||
assert.deepEqual([zuViele.status, zuViele.body.error], [429, 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.'])
|
||||
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 429)
|
||||
assert.equal((await lesen('einstellungen')).status, 200)
|
||||
assert.equal((await lesen('status')).status, 200)
|
||||
assert.equal((await schreiben('POST', 'oeffnen', { offen: false }, { cookie: customerCookie('c2') })).status !== 429, true)
|
||||
})
|
||||
|
||||
test('Fehlertexte von Appwrite verlassen den Server nie (500 ohne Rohtext)', async () => {
|
||||
await aktiviere()
|
||||
aw.failNext({ collection: 'shopProducts', method: 'GET', status: 500, message: 'GEHEIM interner Appwrite-Text' })
|
||||
const res = await lesen('produkte')
|
||||
assert.equal(res.status, 500)
|
||||
assert.deepEqual(res.body, { error: 'Interner Serverfehler' })
|
||||
assert.equal((await lesen('produkte')).status, 200)
|
||||
})
|
||||
Reference in new issue
Block a user