diff --git a/server/index.js b/server/index.js index cdfa158..7c83540 100644 --- a/server/index.js +++ b/server/index.js @@ -17,6 +17,7 @@ import chatRoutes from './routes/chat.js' import aboRoutes from './routes/abo.js' import funktionenRoutes from './routes/funktionen.js' import termineRoutes from './routes/termine.js' +import shopRoutes from './routes/shop.js' import takeoverCreditRoutes from './routes/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgePendingChatUploads } from './services/chatAttachments.js' @@ -79,6 +80,7 @@ app.use('/api/abo/takeover-credits', takeoverCreditRoutes) app.use('/api/abo', aboRoutes) app.use('/api/funktionen', funktionenRoutes) app.use('/api/termine', termineRoutes) +app.use('/api/shop', shopRoutes) app.use('/api/team', teamRoutes) app.use('/api/admin/gitea', adminGiteaRoutes) app.use('/api/admin/website-projects', adminPreviewRoutes) diff --git a/server/mail-templates/T21 Neue Webshop-Anfrage.html b/server/mail-templates/T21 Neue Webshop-Anfrage.html new file mode 100644 index 0000000..0c18a59 --- /dev/null +++ b/server/mail-templates/T21 Neue Webshop-Anfrage.html @@ -0,0 +1,48 @@ + + + + + + + + +[Webshop] Neue Anfrage: {{kunde_name}} + + + + +{{kunde_name}} hat eine Webshop-Anfrage gestellt (Projekt {{projekt}}). ͏ ͏ ͏ + + + + + diff --git a/server/routes/shop.js b/server/routes/shop.js new file mode 100644 index 0000000..80a6629 --- /dev/null +++ b/server/routes/shop.js @@ -0,0 +1,114 @@ +import { Router } from 'express' +import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js' +import { getDocument } from '../services/appwriteAdmin.js' +import { config } from '../config.js' +import { isKundenansicht } from '../services/kundenansicht.js' +import { createWriteLimiter } from '../services/writeLimiter.js' +import { anfrageAnsicht, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js' +import { shopFuerKunde } from '../services/shop/schalter.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. + * + * 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. + * Fehler: { error: , fehler?: { : Text }, code? }; Texte von Appwrite oder unbekannten Fehlern verlassen den Server nie. + */ + +const router = Router() + +const MAX_WRITES_PER_HOUR = 20 +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.', +} + +/** Fehler des Kunden (4xx/503 ohne Appwrite-Typ) wie sie sind, alles andere 500 ohne Rohtext. */ +function fail(res, err) { + const own = ((err?.status >= 400 && err.status < 500) || err?.status === 503) && !err.type + if (!own) { + console.error(`[shop] ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${String(err?.message ?? '').slice(0, 200)}`.trim()) + 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 + return res.status(err.status).json(body) +} + +const route = (fn) => async (req, res, next) => { + try { + await fn(req, res, next) + } catch (err) { + fail(res, err) + } +} + +const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR }) +const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) + +export function resetShopRoutesForTests() { + limiter.reset() +} + +const nurKunde = (req, res, next) => { + if (isKundenansicht(req.session) || req.session?.viewAs) return res.status(403).json({ error: MELDUNG.kundenansicht }) + return next() +} + +const person = (req) => ({ name: req.session?.name || '', email: req.session?.email || '' }) +const projectIdOf = (req) => { + const raw = req.query.projectId ?? req.body?.projectId + return typeof raw === 'string' ? raw : '' +} + +router.use((_req, res, next) => { + res.set('Cache-Control', 'no-store') + next() +}) + +/** + * 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. + */ +router.get('/status', requireSession, route(async (req, res) => { + const projectId = projectIdOf(req) + if (!projectId) throw Object.assign(new Error(MELDUNG.projekt), { status: 400 }) + const customerId = getSessionCustomerId(req) + try { + await assertOwnProject(customerId, projectId) + } catch (err) { + throw err?.status === 403 || err?.status === 400 ? Object.assign(new Error(MELDUNG.fremd), { status: 403 }) : err + } + 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 + } + } + res.json({ verfuegbar, state: feature?.state || 'off', anfrage: shop ? anfrageAnsicht(shop) : null }) +})) + +router.post('/anfrage', requireSession, nurKunde, limitWrites, route(async (req, res) => { + const anfrage = await stelleAnfrage({ + customerId: getSessionCustomerId(req), + projectId: projectIdOf(req), + eingabe: req.body?.anfrage, + person: person(req), + }) + res.json({ anfrage }) +})) + +router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitWrites, route(async (req, res) => { + const anfrage = await zieheZurueck({ customerId: getSessionCustomerId(req), projectId: projectIdOf(req), person: person(req) }) + res.json({ anfrage }) +})) + +export default router diff --git a/server/services/shop/anfrage.js b/server/services/shop/anfrage.js index 6ebabf2..196835f 100644 --- a/server/services/shop/anfrage.js +++ b/server/services/shop/anfrage.js @@ -1,15 +1,39 @@ import { config } from '../../config.js' -import { getDocument } from '../appwriteAdmin.js' -import { getFeatureDoc } from '../siteFeatures.js' +import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { sendAdminNotice } from '../mailer.js' +import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js' +import { shopFuerKunde } from './schalter.js' /** - * Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Dieser Stand ist der Rumpf aus - * Task A3: nur die Bestellpruefung; Block B (B1, B2) baut Anfrage stellen, zurueckziehen und die Team-Entscheidung aus. - * Die Anfrage steht im Dokument shopConfig, dessen ID gleich der ID des portalFeatures-Dokuments des Projekts ist. + * Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Der Kunde stellt eine Anfrage, das Team + * entscheidet (B2: annehmen oder ablehnen); erst eine angenommene Anfrage laesst die Bestellung der Funktion zu + * (anfrageOffenePunkte, Haken bestellpruefung). Die Anfrage steht im Dokument shopConfig, dessen ID gleich der ID des + * portalFeatures-Dokuments des Projekts ist. anfrageStatus: '' (keine) | offen | angenommen | abgelehnt | zurueckgezogen. + * Fehler an den Kunden tragen status (4xx) und einen deutschen Text (Du-Form), optional err.body.code. */ export const ANFRAGE_OFFEN_TEXT = 'Deine Webshop-Anfrage ist noch nicht freigegeben.' +export const WARENARTEN = { waren: 'Waren', lebensmittel: 'Lebensmittel', kleidung: 'Kleidung', kunst: 'Kunst', sonstiges: 'Sonstiges' } +export const ARTIKELZAHLEN = { bis20: 'bis 20', bis100: 'bis 100', mehr: 'mehr als 100' } + +const BESCHREIBUNG_MIN = 10 +const BESCHREIBUNG_MAX = 1500 +const NACHRICHT_MAX = 1000 + +const MELDUNG = { + projekt: 'Bitte wähle ein Projekt.', + nichtVerfuegbar: 'Der Webshop ist noch nicht verfügbar.', + vorhanden: 'Für dieses Projekt gibt es schon einen Webshop.', + laeuft: 'Für dieses Projekt gibt es schon eine Webshop-Anfrage.', + nichtOffen: 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.', + ungueltig: 'Ungültige Eingabe.', +} + +const stop = (status, text, code) => Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}) }) +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + /** * Offene Punkte der Bestellpruefung (featureHaken('shop').bestellpruefung): [] wenn die Anfrage des Projekts vom Team angenommen * ist (anfrageStatus 'angenommen'), sonst ein Punkt. Ohne portalFeatures-Dokument, ohne shopConfig-Dokument oder bei jedem anderen @@ -18,12 +42,191 @@ export const ANFRAGE_OFFEN_TEXT = 'Deine Webshop-Anfrage ist noch nicht freigege export async function anfrageOffenePunkte({ customerId, projectId }) { const doc = await getFeatureDoc(customerId, projectId, 'shop') if (!doc) return [ANFRAGE_OFFEN_TEXT] - let shop - try { - shop = await getDocument(config.collections.shopConfig, doc.$id) - } catch (err) { - if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return [ANFRAGE_OFFEN_TEXT] - throw err - } + const shop = await leseShopConfig(doc.$id) return shop?.anfrageStatus === 'angenommen' ? [] : [ANFRAGE_OFFEN_TEXT] } + +/** shopConfig-Dokument zur ID oder null, wenn es (noch) keines gibt. Fehlt die Collection selbst, wird der Fehler weitergereicht. */ +async function leseShopConfig(id) { + try { + return await getDocument(config.collections.shopConfig, id) + } catch (err) { + if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return null + throw err + } +} + +/** + * shopConfig-Dokument zum portalFeatures-Dokument holen oder anlegen (ID gleich, siteKey kopiert, Anfrage leer, Version 0). + * Zwei gleichzeitige Anlagen: die zweite bekommt 409 und liest das Dokument der ersten. + */ +export async function ensureShopConfig(featureDoc) { + const vorhanden = await leseShopConfig(featureDoc.$id) + if (vorhanden) return vorhanden + try { + return await createDocument( + config.collections.shopConfig, + { + customerId: featureDoc.customerId, + projectId: featureDoc.projectId, + siteKey: featureDoc.siteKey, + anfrageStatus: '', + version: 0, + }, + featureDoc.$id + ) + } catch (err) { + if (err?.status === 409) { + const nochmal = await leseShopConfig(featureDoc.$id) + if (nochmal) return nochmal + } + throw err + } +} + +/** + * Eingabe der Anfrage pruefen => { anfrage } (nur bekannte Felder, bereinigt) oder { fehler: { feld: text } }. + * warenart, beschreibung (10-1500 Zeichen), artikelzahl, versand/abholung (mindestens eins), unternehmer und keineAusgeschlossenen + * muessen true sein, nachricht (optional, bis 1000 Zeichen). + */ +export function normalisiereAnfrage(eingabe) { + const fehler = {} + const e = istObjekt(eingabe) ? eingabe : {} + const text = (wert) => (typeof wert === 'string' ? wert.toWellFormed().trim() : '') + + const warenart = typeof e.warenart === 'string' && Object.hasOwn(WARENARTEN, e.warenart) ? e.warenart : '' + if (!warenart) fehler.warenart = 'Bitte wähle, was du verkaufen möchtest.' + + const beschreibung = text(e.beschreibung) + if (beschreibung.length < BESCHREIBUNG_MIN) fehler.beschreibung = `Bitte beschreibe kurz, was du verkaufen möchtest (mindestens ${BESCHREIBUNG_MIN} Zeichen).` + else if (beschreibung.length > BESCHREIBUNG_MAX) fehler.beschreibung = `Die Beschreibung darf höchstens ${BESCHREIBUNG_MAX} Zeichen lang sein.` + + const artikelzahl = typeof e.artikelzahl === 'string' && Object.hasOwn(ARTIKELZAHLEN, e.artikelzahl) ? e.artikelzahl : '' + if (!artikelzahl) fehler.artikelzahl = 'Bitte wähle, wie viele Artikel du etwa anbieten möchtest.' + + if (e.versand !== undefined && typeof e.versand !== 'boolean') fehler.versand = MELDUNG.ungueltig + if (e.abholung !== undefined && typeof e.abholung !== 'boolean') fehler.abholung = MELDUNG.ungueltig + const versand = e.versand === true + const abholung = e.abholung === true + if (!fehler.versand && !fehler.abholung && !versand && !abholung) fehler.versand = 'Bitte wähle Versand, Abholung oder beides.' + + if (e.unternehmer !== true) fehler.unternehmer = 'Bitte bestätige, dass du als Unternehmer verkaufst.' + if (e.keineAusgeschlossenen !== true) fehler.keineAusgeschlossenen = 'Bitte bestätige, dass du keine ausgeschlossenen Waren verkaufst.' + + let nachricht = '' + if (e.nachricht !== undefined && e.nachricht !== null && e.nachricht !== '') { + if (typeof e.nachricht !== 'string') fehler.nachricht = MELDUNG.ungueltig + else { + nachricht = text(e.nachricht) + if (nachricht.length > NACHRICHT_MAX) fehler.nachricht = `Die Nachricht darf höchstens ${NACHRICHT_MAX} Zeichen lang sein.` + } + } + + if (Object.keys(fehler).length) return { fehler } + return { anfrage: { warenart, beschreibung, artikelzahl, versand, abholung, unternehmer: true, keineAusgeschlossenen: true, nachricht } } +} + +/** Anfrage fuer den Browser: { status, anfrage, at, entschiedenAt, begruendung } oder null, solange es keine gab. Unlesbares JSON => anfrage null. */ +export function anfrageAnsicht(shopDoc) { + const status = shopDoc?.anfrageStatus || '' + if (!status) return null + let anfrage = null + try { + const roh = typeof shopDoc.anfrage === 'string' && shopDoc.anfrage ? JSON.parse(shopDoc.anfrage) : null + anfrage = istObjekt(roh) ? roh : null + } catch { + anfrage = null + } + return { + status, + anfrage, + at: shopDoc.anfrageAt || null, + entschiedenAt: shopDoc.anfrageEntschiedenAt || null, + begruendung: shopDoc.anfrageBegruendung || '', + } +} + +/** Projekt des Kunden (Appwrite-Fehler 400 bei unbrauchbarer ID zaehlt als fremdes Projekt) und Schalter des Kunden. */ +async function pruefeZugang({ customerId, projectId, person, neu }) { + if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt) + let project + try { + project = await assertOwnProject(customerId, projectId, { forNewOrder: neu }) + } catch (err) { + throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err + } + if (!shopFuerKunde(person?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar') + return project +} + +/** + * Anfrage stellen. Reihenfolge: Projekt (403/409 archiviert), Schalter (409), Eingabe (400 mit `fehler`), dann unter Sperre + * je Projekt: Funktion muss `off` sein (409), Anfrage-Status '' | abgelehnt | zurueckgezogen (409). Speichert JSON, Status `offen` + * und anfrageAt, loescht eine fruehere Entscheidung und schickt T21 ans Team (ein Fehler dabei wird nur geloggt). + * @returns {Promise} anfrageAnsicht des neuen Standes + */ +export async function stelleAnfrage({ customerId, projectId, eingabe, person = {}, now = new Date() }) { + const project = await pruefeZugang({ customerId, projectId, person, neu: true }) + const { anfrage, fehler } = normalisiereAnfrage(eingabe) + if (fehler) throw Object.assign(stop(400, Object.values(fehler)[0]), { fehler }) + + const gespeichert = await withLocks([`shop-anfrage:${projectId}`], async () => { + const feature = await ensureFeatureDoc(customerId, projectId, 'shop') + if (feature.state !== 'off') throw stop(409, MELDUNG.vorhanden, 'vorhanden') + const shop = await ensureShopConfig(feature) + if (!['', 'abgelehnt', 'zurueckgezogen'].includes(shop.anfrageStatus || '')) throw stop(409, MELDUNG.laeuft, 'anfrage_laeuft') + return updateDocument(config.collections.shopConfig, shop.$id, { + anfrage: JSON.stringify(anfrage), + anfrageStatus: 'offen', + anfrageAt: now.toISOString(), + anfrageEntschiedenAt: null, + anfrageEntschiedenVon: '', + anfrageBegruendung: '', + version: (Number.isInteger(shop.version) ? shop.version : 0) + 1, + }) + }) + + await meldeTeam({ customerId, projectId, project, anfrage, person }) + return anfrageAnsicht(gespeichert) +} + +/** T21 "Neue Webshop-Anfrage" an das Team; sendAdminNotice wirft nie, ein Fehlschlag steht nur im Log (die Anfrage ist gespeichert). */ +async function meldeTeam({ customerId, projectId, project, anfrage, person }) { + try { + let kundeName = String(person?.name || '').trim() + if (!kundeName) kundeName = String((await customerOf(customerId))?.name || '').trim() + const lieferung = [anfrage.versand ? 'Versand' : '', anfrage.abholung ? 'Abholung' : ''].filter(Boolean).join(' und ') + const result = await sendAdminNotice({ + template: 'T21', + values: { + kunde_name: kundeName || 'Kunde', + email: person?.email || '', + projekt: project?.projectName || projectId, + warenart: WARENARTEN[anfrage.warenart], + beschreibung: anfrage.beschreibung, + artikelzahl: ARTIKELZAHLEN[anfrage.artikelzahl], + lieferung, + nachricht: anfrage.nachricht || '–', + woms_url: `${config.womsBaseUrl}/customers/${encodeURIComponent(customerId)}?tab=funktionen`, + }, + }) + if (!result?.sent) console.warn(`[shop] Team-Mail T21 nicht gesendet: ${result?.reason || 'unbekannt'}`) + } catch (err) { + console.warn(`[shop] Team-Mail T21 nicht gesendet: ${err?.message}`) + } +} + +/** Offene Anfrage zurueckziehen (Status `zurueckgezogen`); aus jedem anderen Zustand 409. @returns {Promise} anfrageAnsicht */ +export async function zieheZurueck({ customerId, projectId, person = {} }) { + await pruefeZugang({ customerId, projectId, person, neu: false }) + const gespeichert = await withLocks([`shop-anfrage:${projectId}`], async () => { + const feature = await getFeatureDoc(customerId, projectId, 'shop') + const shop = feature ? await leseShopConfig(feature.$id) : null + if (!shop || shop.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtOffen, 'nicht_offen') + return updateDocument(config.collections.shopConfig, shop.$id, { + anfrageStatus: 'zurueckgezogen', + version: (Number.isInteger(shop.version) ? shop.version : 0) + 1, + }) + }) + return anfrageAnsicht(gespeichert) +} diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js index 7929da4..218ab52 100644 --- a/test/kundenansichtGuard.test.js +++ b/test/kundenansichtGuard.test.js @@ -38,6 +38,7 @@ const MOUNTS = [ ['/api/abo', (await import('../server/routes/abo.js')).default], ['/api/funktionen', (await import('../server/routes/funktionen.js')).default], ['/api/termine', termineRouter], + ['/api/shop', (await import('../server/routes/shop.js')).default], ] // Oeffentliche Schnittstelle (ohne Session) wie in server/index.js hinter dem Waechter; nicht in MOUNTS, weil sie keine Kunden-Route ist const oeffentlich = (await import('../server/routes/publicTermine.js')).default @@ -140,6 +141,8 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L ['PUT', '/api/termine/einstellungen'], ['PATCH', '/api/termine/buchungen/abc'], ['PATCH', '/api/termine/einstellungen/x'], ['DELETE', '/api/termine/einstellungen'], ['DELETE', '/api/termine/buchungen'], ['DELETE', '/api/termine/buchungen/abc/def'], ['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'], ]) { assert.ok(!allowed(m, p), `${m} ${p}`) } diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 072637b..da9d5da 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -6,9 +6,9 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products. const DIR = new URL('../server/mail-templates/', import.meta.url) // G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb; -// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) +// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`) -const EXPECTED = [...ids('G', 8), ...ids('K', 23), ...ids('T', 19), 'T20'] +const EXPECTED = [...ids('G', 8), ...ids('K', 23), ...ids('T', 19), 'T20', 'T21'] const GUEST = ids('G', 8) const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) @@ -24,8 +24,8 @@ for (const id of EXPECTED) { const source = readFileSync(new URL(tpl.file, DIR), 'utf8') const title = source.match(/([\s\S]*?)<\/title>/)[1] assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich') - // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail), [Finanzen] (Bankzugang) oder [Terminplaner] (Betriebsmeldung) - assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen] oder [Terminplaner] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)') + // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail), [Finanzen] (Bankzugang), [Terminplaner] (Betriebsmeldung) oder [Webshop] (neue Anfrage) + assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner] oder [Webshop] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)') const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1])) assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter') @@ -70,6 +70,20 @@ test('T20: Betreff [Terminplaner], Platzhalter vorfall/details/zeit, keine Blöc for (const line of ['Mailkontingent erreicht', 'Zeile 1\nZeile <2>', '04.10.2026, 10:00']) assert.ok(mail.text.includes(line), `Text enthält: ${line}\n---\n${mail.text}`) }) +// Webshop B1: Team-Hinweis auf eine neue Anfrage (Versand ueber sendAdminNotice, services/shop/anfrage.js) +test('T21: Betreff [Webshop], Platzhalter der Anfrage, keine Blöcke, Knopf zum Kunden im Ticketsystem', () => { + const tpl = loadTemplate('T21') + assert.equal(tpl.file, 'T21 Neue Webshop-Anfrage.html') + assert.equal(tpl.subject, '[Webshop] Neue Anfrage: {{kunde_name}}') + assert.deepEqual(tpl.placeholders, ['kunde_name', 'email', 'projekt', 'warenart', 'beschreibung', 'artikelzahl', 'lieferung', 'nachricht', 'woms_url']) + assert.deepEqual(tpl.blocks, []) + const mail = renderMail('T21', { ...sampleValues(tpl), beschreibung: 'Zeile 1\nZeile <2>', woms_url: 'https://ticket.webklar.com/customers/c1?tab=funktionen' }) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, '[Webshop] Neue Anfrage: Wert-kunde_name') + assert.match(mail.html, /Zeile 1<br>Zeile <2>/) + assert.match(mail.html, /<a href="https:\/\/ticket\.webklar\.com\/customers\/c1\?tab=funktionen"/) +}) + // Terminplaner TP2 (B9a): K15 und K16 gelten für jede Funktion des Registers; der Wortlaut je Funktion kommt aus den Platzhaltern test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text', () => { const k15 = loadTemplate('K15') @@ -699,7 +713,7 @@ test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => { }) // Etappe 4: das Admin-Panel ist abgeschaltet, Team-Mails fuehren ins Ticketsystem -const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20'] +const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21'] test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => { for (const id of EXPECTED) { diff --git a/test/shopAnfrage.test.js b/test/shopAnfrage.test.js new file mode 100644 index 0000000..2006163 --- /dev/null +++ b/test/shopAnfrage.test.js @@ -0,0 +1,400 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Webshop B1: Anfrage stellen, zurueckziehen, Stand lesen (services/shop/anfrage.js, routes/shop.js), T21 ans Team, +// Sperre in der Kundenansicht (kundenansichtGuard, Router) +const aw = await startAppwriteMock({ + schema: { portalFeatures: schemaKeys('portalFeatures'), shopConfig: schemaKeys('shopConfig'), featureConsents: schemaKeys('featureConsents') }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, send, get, 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 anfrage = await import('../server/services/shop/anfrage.js') +const sf = await import('../server/services/siteFeatures.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { mailSink } = await import('./support/mailSink.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const { featureHaken } = await import('../server/services/featureRegistry.js') + +const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes }) +const mails = mailSink() +mails.versandFehler = null +const sinkMail = mails.sendMail.bind(mails) +mails.sendMail = async (mail) => { + if (mails.versandFehler) throw new Error('SMTP nicht erreichbar') + return sinkMail(mail) +} +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + return 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 GUELTIG = { + warenart: 'kunst', + beschreibung: 'Handgemachte Keramik und Tassen aus eigener Werkstatt.', + artikelzahl: 'bis20', + versand: true, + abholung: false, + unternehmer: true, + keineAusgeschlossenen: true, + nachricht: 'Wir würden gern vor Weihnachten starten.', +} +const stelle = (body = {}, opts = { cookie }) => post('/api/shop/anfrage', { projectId: 'pt1', anfrage: GUELTIG, ...body }, opts) +const zurueck = (projectId = 'pt1', opts = { cookie }) => post('/api/shop/anfrage/zurueckziehen', { projectId }, opts) +const status = (projectId = 'pt1', opts = { cookie }) => send('GET', `/api/shop/status?projectId=${projectId}`, opts) +const t21 = () => mails.sent.filter((m) => /^\[Webshop\]/.test(m.subject)) +const shopDoc = (projectId = 'pt1') => aw.docs('shopConfig').get(`${projectId}`) ?? null + +let origin +beforeEach(() => { + origin ??= { mode: config.shop.mode, pilotEmails: config.shop.pilotEmails } + config.shop.mode = 'pilot' + config.shop.pilotEmails = [customers.c1.email] + mails.versandFehler = null + resetShopRoutesForTests() +}) + +async function leereAlles() { + for (const col of ['portalFeatures', 'shopConfig']) for (const id of aw.docs(col).keys()) await deleteDocument(config.collections[col], id) + mails.sent.length = 0 +} + +test('normalisiereAnfrage: gueltige Eingabe, bereinigt; Fehler je Feld', () => { + const { anfrage: ok, fehler } = anfrage.normalisiereAnfrage({ ...GUELTIG, beschreibung: ' Handgemachte Tassen und mehr ', extra: 'x' }) + assert.equal(fehler, undefined) + assert.deepEqual(Object.keys(ok).sort(), ['abholung', 'artikelzahl', 'beschreibung', 'keineAusgeschlossenen', 'nachricht', 'unternehmer', 'versand', 'warenart']) + assert.equal(ok.beschreibung, 'Handgemachte Tassen und mehr') + + const f = (aenderung) => anfrage.normalisiereAnfrage({ ...GUELTIG, ...aenderung }).fehler + assert.ok(f({ warenart: 'drogen' }).warenart) + assert.ok(f({ warenart: 'constructor' }).warenart, 'kein Treffer ueber die Prototypkette') + assert.ok(f({ beschreibung: 'kurz' }).beschreibung) + assert.ok(f({ beschreibung: 'x'.repeat(1501) }).beschreibung) + assert.equal(f({ beschreibung: 'x'.repeat(1500) }), undefined) + assert.ok(f({ artikelzahl: 'viele' }).artikelzahl) + assert.ok(f({ versand: false, abholung: false }).versand, 'mindestens eins von Versand und Abholung') + assert.equal(f({ versand: false, abholung: true }), undefined) + assert.ok(f({ versand: 'ja' }).versand) + assert.ok(f({ unternehmer: 'true' }).unternehmer) + assert.ok(f({ unternehmer: false }).unternehmer) + assert.ok(f({ keineAusgeschlossenen: undefined }).keineAusgeschlossenen) + assert.ok(f({ nachricht: 'n'.repeat(1001) }).nachricht) + assert.equal(f({ nachricht: undefined }), undefined) + assert.equal(anfrage.normalisiereAnfrage({ ...GUELTIG, nachricht: undefined }).anfrage.nachricht, '') + assert.ok(anfrage.normalisiereAnfrage(null).fehler.warenart) + assert.ok(anfrage.normalisiereAnfrage([]).fehler.beschreibung) +}) + +test('GET /status: ohne Dokumente off und leer, legt nichts an; Schalter, fremdes Projekt, ohne Projekt', async () => { + await leereAlles() + const res = await status() + assert.equal(res.status, 200) + assert.deepEqual(res.body, { verfuegbar: true, state: 'off', anfrage: 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') + + config.shop.mode = 'off' + assert.equal((await status()).body.verfuegbar, false) + config.shop.mode = 'pilot' + config.shop.pilotEmails = ['jemand-anderes@example.invalid'] + assert.equal((await status()).body.verfuegbar, false, 'Pilot: nur gelistete Adressen') + + assert.equal((await status('pt3')).status, 403) + assert.equal((await status('gibtsnicht')).status, 403) + assert.equal((await send('GET', '/api/shop/status', { cookie })).status, 400) + assert.equal((await send('GET', '/api/shop/status?projectId=pt1')).status, 401) +}) + +test('POST /anfrage: Schalter aus => 409, nichts angelegt', async () => { + await leereAlles() + config.shop.mode = 'off' + const res = await stelle() + assert.equal(res.status, 409) + assert.equal(res.body.error, 'Der Webshop ist noch nicht verfügbar.') + assert.equal(res.body.code, 'nicht_verfuegbar') + config.shop.mode = 'pilot' + config.shop.pilotEmails = [] + assert.equal((await stelle()).status, 409) + assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0) + assert.equal(t21().length, 0) +}) + +test('POST /anfrage: fremdes oder archiviertes Projekt, ohne Projekt, ungueltige Eingabe => nichts angelegt, keine Mail', async () => { + await leereAlles() + assert.equal((await stelle({ projectId: 'pt3' })).status, 403) + assert.equal((await stelle({ projectId: 'unbekannt' })).status, 403) + assert.equal((await stelle({ projectId: 'pt4' })).status, 409, 'archiviert') + const ohne = await post('/api/shop/anfrage', { anfrage: GUELTIG }, { cookie }) + assert.equal(ohne.status, 400) + assert.equal(ohne.body.error, 'Bitte wähle ein Projekt.') + const schlecht = await stelle({ anfrage: { ...GUELTIG, beschreibung: 'kurz', unternehmer: false } }) + assert.equal(schlecht.status, 400) + assert.ok(schlecht.body.fehler.beschreibung && schlecht.body.fehler.unternehmer) + assert.equal(schlecht.body.error, Object.values(schlecht.body.fehler)[0]) + assert.equal((await stelle({ anfrage: 'text' })).status, 400) + assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0) + assert.equal(t21().length, 0) + assert.equal((await post('/api/shop/anfrage', { projectId: 'pt1', anfrage: GUELTIG })).status, 401) +}) + +test('POST /anfrage: speichert, Status offen, shopConfig-ID = portalFeatures-ID, T21 ans Team', async () => { + await leereAlles() + const res = await stelle() + assert.equal(res.status, 200) + assert.equal(res.body.anfrage.status, 'offen') + assert.deepEqual(res.body.anfrage.anfrage, { ...GUELTIG }) + assert.match(res.body.anfrage.at, /^\d{4}-\d\d-\d\dT/) + assert.equal(res.body.anfrage.begruendung, '') + + const feature = await sf.getFeatureDoc('c1', 'pt1', 'shop') + assert.equal(feature.state, 'off', 'Funktion bleibt off, die Anfrage ist keine Bestellung') + const shop = aw.docs('shopConfig').get(feature.$id) + assert.ok(shop, 'shopConfig mit der ID des portalFeatures-Dokuments') + assert.deepEqual([shop.customerId, shop.projectId, shop.siteKey, shop.anfrageStatus, shop.version], ['c1', 'pt1', feature.siteKey, 'offen', 1]) + assert.deepEqual(JSON.parse(shop.anfrage), GUELTIG) + assert.equal(aw.docs('shopConfig').size, 1) + + // GET zeigt dasselbe + const stand = await status() + assert.deepEqual([stand.body.verfuegbar, stand.body.state, stand.body.anfrage.status], [true, 'off', 'offen']) + assert.deepEqual(stand.body.anfrage.anfrage, GUELTIG) + + // T21 + assert.equal(t21().length, 1) + const mail = t21()[0] + assert.equal(mail.subject, '[Webshop] Neue Anfrage: Kunde Eins') + assert.ok(mail.to.length > 0 && mail.to.every((x) => config.chatNotify.emails.includes(x.address)), 'nur ans Team') + for (const teil of ['k@example.invalid', 'Töpferei', 'Kunst', 'bis 20', 'Versand', 'Handgemachte Keramik', 'vor Weihnachten']) assert.ok(mail.text.includes(teil), `T21 nennt ${teil}\n${mail.text}`) + assert.ok(!/Abholung/.test(mail.text.split('Lieferung')[1].split('\n')[0]), 'Lieferung: nur Versand') + assert.ok(mail.html.includes(`${config.womsBaseUrl}/customers/c1?tab=funktionen`), 'Link zum Kunden im Ticketsystem') +}) + +test('POST /anfrage: Lieferung beides; ohne Nachricht steht ein Strich; Rohtext wird maskiert', async () => { + await leereAlles() + const res = await stelle({ anfrage: { ...GUELTIG, versand: true, abholung: true, nachricht: undefined, beschreibung: 'Tassen <b>fett</b> & mehr' } }) + assert.equal(res.status, 200) + const mail = t21()[0] + assert.ok(mail.text.includes('Versand und Abholung')) + assert.ok(mail.html.includes('Tassen <b>fett</b> & mehr')) + assert.ok(!mail.html.includes('<b>fett</b>')) + assert.match(mail.text, /Nachricht:\s*–/) +}) + +test('zweite Anfrage bei offener => 409, keine zweite Mail; zurueckziehen, dann wieder stellen (Version zaehlt hoch)', async () => { + await leereAlles() + assert.equal((await stelle()).status, 200) + const doppelt = await stelle() + assert.equal(doppelt.status, 409) + assert.equal(doppelt.body.code, 'anfrage_laeuft') + assert.equal(t21().length, 1) + + const zu = await zurueck() + assert.equal(zu.status, 200) + assert.equal(zu.body.anfrage.status, 'zurueckgezogen') + assert.equal(shopDoc(), null, 'ID ist die des portalFeatures-Dokuments, nicht die Projekt-ID') + const feature = await sf.getFeatureDoc('c1', 'pt1', 'shop') + assert.equal(aw.docs('shopConfig').get(feature.$id).version, 2) + const nochmal = await zurueck() + assert.equal(nochmal.status, 409) + assert.equal(nochmal.body.code, 'nicht_offen') + assert.equal(nochmal.body.error, 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.') + + const neu = await stelle({ anfrage: { ...GUELTIG, warenart: 'kleidung' } }) + assert.equal(neu.status, 200) + assert.equal(neu.body.anfrage.status, 'offen') + assert.equal(neu.body.anfrage.anfrage.warenart, 'kleidung') + assert.equal(aw.docs('shopConfig').get(feature.$id).version, 3) + assert.equal(aw.docs('shopConfig').size, 1, 'immer dasselbe Dokument') + assert.equal(t21().length, 2) +}) + +test('zurueckziehen ohne Anfrage, bei fremdem Projekt, bei ausgeschaltetem Schalter', async () => { + await leereAlles() + assert.equal((await zurueck()).status, 409, 'es gibt keine Anfrage') + assert.equal((await zurueck('pt3')).status, 403) + assert.equal((await post('/api/shop/anfrage/zurueckziehen', {}, { cookie })).status, 400) + assert.equal(aw.docs('portalFeatures').size, 0, 'zurueckziehen legt nichts an') + config.shop.mode = 'off' + const aus = await zurueck() + assert.equal(aus.status, 409) + assert.equal(aus.body.code, 'nicht_verfuegbar') +}) + +test('abgelehnt erlaubt eine neue Anfrage und leert die Entscheidung; angenommen und andere Zustaende nicht', async () => { + await leereAlles() + assert.equal((await stelle()).status, 200) + const feature = await sf.getFeatureDoc('c1', 'pt1', 'shop') + const id = feature.$id + const setze = (daten) => updateDocument(config.collections.shopConfig, id, daten) + + await setze({ anfrageStatus: 'abgelehnt', anfrageEntschiedenAt: new Date().toISOString(), anfrageEntschiedenVon: 'Andrej <andrej@webklar.com>', anfrageBegruendung: 'Alkohol ist ausgeschlossen.' }) + const abgelehnt = await status() + assert.equal(abgelehnt.body.anfrage.status, 'abgelehnt') + assert.equal(abgelehnt.body.anfrage.begruendung, 'Alkohol ist ausgeschlossen.') + assert.match(abgelehnt.body.anfrage.entschiedenAt, /^\d{4}-\d\d-\d\dT/) + assert.equal((await zurueck()).status, 409, 'abgelehnt laesst sich nicht zurueckziehen') + const neu = await stelle() + assert.equal(neu.status, 200) + assert.equal(neu.body.anfrage.begruendung, '') + assert.equal(neu.body.anfrage.entschiedenAt, null) + assert.equal(aw.docs('shopConfig').get(id).anfrageEntschiedenVon, '') + + await setze({ anfrageStatus: 'angenommen' }) + const angenommen = await stelle() + assert.equal(angenommen.status, 409) + assert.equal(angenommen.body.code, 'anfrage_laeuft') + assert.equal((await zurueck()).status, 409) + + // Funktion nicht mehr off (bestellt): keine neue Anfrage, auch wenn der Status es erlauben wuerde + await setze({ anfrageStatus: 'abgelehnt' }) + await updateDocument(config.collections.portalFeatures, id, { state: 'ordered' }) + const bestellt = await stelle() + assert.equal(bestellt.status, 409) + assert.equal(bestellt.body.code, 'vorhanden') + assert.equal(bestellt.body.error, 'Für dieses Projekt gibt es schon einen Webshop.') + assert.equal((await status()).body.state, 'ordered') +}) + +test('Mailfehler: Anfrage ist trotzdem gespeichert (200), Fehler nur im Log', async () => { + await leereAlles() + mails.versandFehler = true + const warnungen = [] + const warn = console.warn + console.warn = (...a) => warnungen.push(a.join(' ')) + const error = console.error + console.error = () => {} + try { + const res = await stelle() + assert.equal(res.status, 200) + assert.equal(res.body.anfrage.status, 'offen') + } finally { + console.warn = warn + console.error = error + } + assert.equal(t21().length, 0) + assert.ok(warnungen.some((w) => w.includes('T21')), 'Log nennt T21') + assert.ok(!warnungen.some((w) => w.includes('k@example.invalid') || w.includes('Handgemachte')), 'keine Kundendaten im Log') + assert.equal([...aw.docs('shopConfig').values()][0].anfrageStatus, 'offen') +}) + +test('gleichzeitige Anfragen: genau eine gewinnt, eine Mail, ein shopConfig', async () => { + await leereAlles() + const antworten = await Promise.all([stelle(), stelle(), stelle()]) + assert.deepEqual(antworten.map((a) => a.status).sort(), [200, 409, 409]) + assert.equal(aw.docs('shopConfig').size, 1) + assert.equal(t21().length, 1) +}) + +test('ensureShopConfig: legt an, gleichzeitige Aufrufe teilen sich das Dokument (409 -> lesen)', async () => { + await leereAlles() + const feature = await sf.ensureFeatureDoc('c1', 'pt2', 'shop') + const [a, b, c] = await Promise.all([anfrage.ensureShopConfig(feature), anfrage.ensureShopConfig(feature), anfrage.ensureShopConfig(feature)]) + assert.equal(a.$id, feature.$id) + assert.deepEqual([b.$id, c.$id], [feature.$id, feature.$id]) + assert.equal(aw.docs('shopConfig').size, 1) + const doc = aw.docs('shopConfig').get(feature.$id) + assert.deepEqual([doc.customerId, doc.projectId, doc.siteKey, doc.anfrageStatus, doc.version], ['c1', 'pt2', feature.siteKey, '', 0]) + assert.equal(anfrage.anfrageAnsicht(doc), null, 'ohne Anfrage keine Ansicht') + // vorhandenes Dokument bleibt unveraendert + await updateDocument(config.collections.shopConfig, feature.$id, { version: 7 }) + assert.equal((await anfrage.ensureShopConfig(feature)).version, 7) +}) + +test('anfrageAnsicht: unlesbares JSON gibt anfrage null', () => { + assert.deepEqual(anfrage.anfrageAnsicht({ anfrageStatus: 'offen', anfrage: '{kaputt', anfrageAt: '2026-10-05T10:00:00.000+00:00' }), { + status: 'offen', + anfrage: null, + at: '2026-10-05T10:00:00.000+00:00', + entschiedenAt: null, + begruendung: '', + }) + assert.equal(anfrage.anfrageAnsicht(null), null) +}) + +test('anfrageOffenePunkte (Haken bestellpruefung): nur angenommen gibt []', async () => { + await leereAlles() + const punkte = () => featureHaken('shop').bestellpruefung({ customerId: 'c1', projectId: 'pt1' }) + assert.deepEqual(await punkte(), [anfrage.ANFRAGE_OFFEN_TEXT], 'ohne Dokumente') + assert.equal((await stelle()).status, 200) + assert.deepEqual(await punkte(), [anfrage.ANFRAGE_OFFEN_TEXT], 'offen') + const feature = await sf.getFeatureDoc('c1', 'pt1', 'shop') + await updateDocument(config.collections.shopConfig, feature.$id, { anfrageStatus: 'angenommen' }) + assert.deepEqual(await punkte(), []) + assert.deepEqual(await anfrage.anfrageOffenePunkte({ customerId: 'c2', projectId: 'pt1' }), [anfrage.ANFRAGE_OFFEN_TEXT], 'fremder Kunde') +}) + +test('Rate-Limit: 20 Schreibaufrufe je Stunde und Kunde, dann 429; GET zaehlt nicht; anderer Kunde frei', async () => { + await leereAlles() + for (let i = 0; i < 20; i += 1) assert.equal((await stelle({ projectId: 'gibtsnicht' })).status, 403, `Aufruf ${i + 1}`) + const zuViele = await stelle({ projectId: 'gibtsnicht' }) + assert.equal(zuViele.status, 429) + assert.match(zuViele.body.error, /Zu viele/) + assert.equal((await zurueck()).status, 429, 'zurueckziehen zaehlt mit') + assert.equal((await status()).status, 200) + assert.equal((await stelle({ projectId: 'pt3' }, { cookie: customerCookie('c2') })).status, 409, 'c2: nicht im Pilot, aber nicht begrenzt') +}) + +// Kundenansicht: die Schreib-Routen stehen nicht auf der Erlaubnisliste des Waechters; der Router sperrt zusaetzlich selbst +test('Kundenansicht: POSTs gesperrt (Waechter view_blocked), GET /status erlaubt', async () => { + await leereAlles() + const kv = { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } } + const a = await stelle({}, kv) + assert.equal(a.status, 403) + assert.equal(a.body.code, 'view_blocked') + const z = await zurueck('pt1', kv) + assert.equal(z.status, 403) + assert.equal(z.body.code, 'view_blocked') + const lesen = await status('pt1', kv) + assert.equal(lesen.status, 200) + assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0) + assert.equal(t21().length, 0) +}) + +test('Kundenansicht: der Router sperrt selbst, auch wenn der Waechter fehlt (403 mit eigenem Text)', async () => { + await leereAlles() + 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/anfrage', { projectId: 'pt1', anfrage: GUELTIG }], ['/api/shop/anfrage/zurueckziehen', { projectId: 'pt1' }]]) { + 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(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0) +}) + +test('normaler Kunde im Pilot wird vom Waechter nicht beruehrt (Gegenprobe)', async () => { + await leereAlles() + const res = await stelle() + assert.equal(res.status, 200) + assert.equal((await get('/api/shop/status?projectId=pt1', { cookie })).anfrage.status, 'offen') +})