Webshop B1: Anfrage-Dienst, Kundenroute /api/shop und Team-Mail T21
Anfrage stellen, zurückziehen und Stand lesen (ensureShopConfig, normalisiereAnfrage, stelleAnfrage, zieheZurueck, anfrageAnsicht), Router /api/shop mit Rate-Limit 20/h und Sperre in der Kundenansicht, Vorlage T21 Neue Webshop-Anfrage mit Tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0063c04d8a
commit
83605975b0
7 files changed
+801
-17
No files matched your search
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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(/<title>([\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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in new issue
Block a user