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', 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') 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 fett & 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('fett')) 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 ', 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') })