kleinunternehmer ist ab jetzt null (nicht gewaehlt), lieferzeit leer. istVollstaendig verlangt eine bewusste Wahl (true oder false), der Shop kann sonst nicht geoeffnet werden. Im Reiter Einstellungen gibt es zwei Pflicht-Radiobuttons ohne Vorauswahl; Preise, Artikel und Muster rechnen bei null wie bei Regelbesteuerung. Tests und Fixtures angepasst. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
430 lines
20 KiB
JavaScript
430 lines
20 KiB
JavaScript
import { test, after, beforeEach, afterEach } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||
import { tmpdir } from 'node:os'
|
||
import http from 'node:http'
|
||
import path from 'node:path'
|
||
import express from 'express'
|
||
import sharp from 'sharp'
|
||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||
import { schemaKeys } from './support/schema.js'
|
||
import { useTestEnv } from './support/testEnv.js'
|
||
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
|
||
|
||
// Oeffentliche Schnittstelle des Webshops, lesender Teil (F1): Konfiguration samt Artikeln und Artikelbilder.
|
||
const aw = await startAppwriteMock({
|
||
schema: {
|
||
portalFeatures: schemaKeys('portalFeatures'),
|
||
shopConfig: schemaKeys('shopConfig'),
|
||
shopProducts: schemaKeys('shopProducts'),
|
||
shopOrders: schemaKeys('shopOrders'),
|
||
},
|
||
datetimes: SHOP_DATETIMES,
|
||
defaultLimit: 25,
|
||
})
|
||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||
|
||
const { config } = await import('../server/config.js')
|
||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||
const { startTestApp, send } = await import('./support/testApp.js')
|
||
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
|
||
const K = await import('../server/services/shop/kontext.js')
|
||
const { normalisiereProdukt } = await import('../server/services/shop/produkte.js')
|
||
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
|
||
const { rechtstexteHash, GEWAEHRLEISTUNG_BILD } = await import('../server/services/shop/rechtstexte.js')
|
||
const { formToken, pruefeFormToken } = await import('../server/services/shop/token.js')
|
||
const { darfAnfrage, resetTermineLimitsForTests, zaehle } = await import('../server/services/termine/limits.js')
|
||
const { default: router, setzeUhrFuerTests, resetPublicShopForTests } = await import('../server/routes/publicShop.js')
|
||
|
||
const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/shop/v1': router })
|
||
const uploadDir = mkdtempSync(path.join(tmpdir(), 'shop-public-'))
|
||
after(() => Promise.all([app.close(), aw.close()]).then(() => rmSync(uploadDir, { recursive: true, force: true })))
|
||
|
||
const BASIS = '/api/public/shop/v1'
|
||
const JETZT = new Date('2026-10-05T12:00:00.000Z')
|
||
const KUNDE = 'c_kunde01'
|
||
const PROJEKT = 'p_projekt01'
|
||
const FEATURE = 'f_p_projekt01_shop'
|
||
const SITE_KEY = 'abcdefghijklmnop'
|
||
const BILD_ID = 'b1b1b1b1-aaaa-bbbb-cccc-111122223333'
|
||
const FREMD_BILD = 'f0f0f0f0-aaaa-bbbb-cccc-111122223333'
|
||
const VORHER = {
|
||
mode: config.shop.mode,
|
||
pilot: [...config.shop.pilotEmails],
|
||
uploadDir: config.shop.uploadDir,
|
||
basis: config.publicBaseUrl,
|
||
formMin: config.shop.formMinSekunden,
|
||
}
|
||
|
||
const EINSTELLUNGEN = {
|
||
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
|
||
kleinunternehmer: true,
|
||
lieferzeit: '2–4 Werktage',
|
||
shopname: 'Brot & Mehr',
|
||
akzent: '#aa3300',
|
||
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '0631 123', ustId: 'DE123456789' },
|
||
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 },
|
||
abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' },
|
||
zahlungsarten: ['card', 'sepa_debit'],
|
||
mindestbestellCent: 1000,
|
||
kleinunternehmer: false,
|
||
}
|
||
const TEXTE = {
|
||
agb: 'AGB-Text',
|
||
widerrufsbelehrung: 'Widerruf-Text',
|
||
widerrufsformular: 'Formular-Text',
|
||
impressumUrl: 'https://brot.example/impressum',
|
||
datenschutzUrl: 'https://brot.example/datenschutz',
|
||
}
|
||
|
||
const featureDoc = (over = {}) => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true, ...over })
|
||
const shopDoc = (over = {}) => ({
|
||
$id: FEATURE,
|
||
customerId: KUNDE,
|
||
projectId: PROJEKT,
|
||
siteKey: SITE_KEY,
|
||
anfrageStatus: 'angenommen',
|
||
stripeAccountId: 'acct_geheim123',
|
||
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }),
|
||
einstellungen: JSON.stringify(EINSTELLUNGEN),
|
||
rechtstexte: JSON.stringify({
|
||
entwurf: { ...TEXTE, agb: 'ENTWURF-AGB' },
|
||
bestaetigt: { texte: TEXTE, hash: rechtstexteHash(TEXTE, EINSTELLUNGEN), at: '2026-10-05T10:00:00.000Z', von: 'kunde@example.invalid' },
|
||
}),
|
||
geoeffnet: true,
|
||
version: 3,
|
||
...over,
|
||
})
|
||
let zaehler = 0
|
||
const artikel = (id, over = {}, produktOver = {}) => {
|
||
const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 450, ...produktOver }, { einstellungen: EINSTELLUNGEN })
|
||
assert.equal(r.fehler, undefined, JSON.stringify(r.fehler))
|
||
return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: ++zaehler, name: r.produkt.name, produkt: JSON.stringify({ ...r.produkt, ...(produktOver.bilder ? { bilder: produktOver.bilder } : {}) }), ...over }
|
||
}
|
||
|
||
async function aufbau({ feature = {}, shop = {}, produkte, kunde = { email: 'inhaber@brot.example' } } = {}) {
|
||
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects', 'customers']) {
|
||
for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id)
|
||
}
|
||
K.leereCache()
|
||
resetTermineLimitsForTests()
|
||
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen' })
|
||
if (kunde) aw.seed('customers', { $id: KUNDE, ...kunde })
|
||
aw.seed('portalFeatures', featureDoc(feature))
|
||
if (shop !== null) aw.seed('shopConfig', shopDoc(shop))
|
||
const liste = produkte ?? [
|
||
artikel('a1', {}, { kategorie: 'Brot', bilder: [BILD_ID] }),
|
||
artikel('a2', {}, { name: 'Zweites Brot', kategorie: 'Brot', bestand: 3 }),
|
||
artikel('a3', {}, { name: 'Kuchen', kategorie: 'Süßes' }),
|
||
artikel('a4', { aktiv: false }, { name: 'Verstecktes', kategorie: 'Geheim' }),
|
||
artikel('a5', {}, { name: 'Ohne Kategorie' }),
|
||
]
|
||
for (const p of liste) aw.seed('shopProducts', p)
|
||
}
|
||
|
||
const kopfzeile = (res, name) => res.headers.get(name)
|
||
let ip
|
||
let n = 0
|
||
const api = (pfad, { ip: adresse = ip, headers = {} } = {}) => send('GET', BASIS + pfad, { headers: { 'x-forwarded-for': adresse, ...headers } })
|
||
|
||
// Nichts Internes in irgendeiner Antwort
|
||
const VERBOTEN = ['customerId', 'projectId', 'featureId', 'acct_', 'stripeAccountId', 'stripeStatus', '$id', '$collectionId', '$createdAt', 'inhaber@brot.example', KUNDE, PROJEKT, FEATURE, 'DE123456789', 'ENTWURF-AGB']
|
||
const ohneInternes = (body) => {
|
||
const s = JSON.stringify(body)
|
||
for (const v of VERBOTEN) assert.ok(!s.includes(v), `interner Wert in der Antwort: ${v}`)
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
zaehler = 0
|
||
ip = `10.1.${Math.floor(++n / 250)}.${(n % 250) + 1}`
|
||
config.shop.mode = 'on'
|
||
config.shop.pilotEmails = []
|
||
config.shop.uploadDir = uploadDir
|
||
config.publicBaseUrl = 'https://portal.example'
|
||
setzeUhrFuerTests(() => JETZT)
|
||
await aufbau()
|
||
})
|
||
afterEach(() => {
|
||
resetPublicShopForTests()
|
||
config.shop.mode = VORHER.mode
|
||
config.shop.pilotEmails = VORHER.pilot
|
||
config.shop.uploadDir = VORHER.uploadDir
|
||
config.publicBaseUrl = VORHER.basis
|
||
config.shop.formMinSekunden = VORHER.formMin
|
||
})
|
||
|
||
test('GET /:siteKey: Shop offen, Aufbau der Antwort und Kopfzeilen', async () => {
|
||
const res = await api(`/${SITE_KEY}`)
|
||
assert.equal(res.status, 200)
|
||
assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*')
|
||
assert.equal(kopfzeile(res, 'cache-control'), 'no-store')
|
||
assert.equal(kopfzeile(res, 'x-content-type-options'), 'nosniff')
|
||
const b = res.body
|
||
assert.equal(b.aktiv, true)
|
||
assert.equal(b.offen, true)
|
||
assert.equal(b.nurWiderruf, undefined)
|
||
assert.equal(b.shop.name, 'Brot & Mehr')
|
||
assert.equal(b.shop.akzent, '#aa3300')
|
||
assert.deepEqual(b.shop.anbieter, { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '0631 123' })
|
||
assert.deepEqual(b.shop.lieferlaender, [{ code: 'DE', name: 'Deutschland' }, { code: 'AT', name: 'Österreich' }])
|
||
assert.deepEqual(b.shop.abholung, { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' })
|
||
assert.equal(b.shop.lieferzeit, EINSTELLUNGEN.lieferzeit)
|
||
assert.equal(b.shop.zahlungsarten, 'Kreditkarte, Apple Pay, Google Pay, SEPA-Lastschrift')
|
||
assert.deepEqual(b.shop.zahlungsartenListe, ['Kreditkarte, Apple Pay, Google Pay', 'SEPA-Lastschrift'])
|
||
assert.equal(b.shop.versandText, 'Versand nach Deutschland 4,90 €, Österreich 9,90 €, ab 50,00 € versandkostenfrei')
|
||
assert.equal(b.shop.mindestbestellText, 'Mindestbestellwert 10,00 €')
|
||
assert.equal(b.shop.kleinunternehmer, false)
|
||
assert.equal(b.shop.steuerHinweisKurz, 'Alle Preise inkl. MwSt.')
|
||
assert.deepEqual(b.kategorien, ['Brot', 'Süßes'])
|
||
assert.deepEqual(b.produkte.map((p) => p.id), ['a1', 'a2', 'a3', 'a5'])
|
||
assert.equal(b.produkte[0].preisText, '4,50 €')
|
||
assert.deepEqual(b.produkte[0].bilder, [{ klein: `${BASIS}/${SITE_KEY}/bild/${BILD_ID}-s.webp`, gross: `${BASIS}/${SITE_KEY}/bild/${BILD_ID}-l.webp` }])
|
||
assert.equal(b.rechtstexte.agb, 'AGB-Text')
|
||
assert.equal(b.rechtstexte.widerrufsbelehrung, 'Widerruf-Text')
|
||
assert.equal(b.rechtstexte.widerrufsformular, 'Formular-Text')
|
||
assert.match(b.rechtstexte.versandUndZahlung, /Liefergebiet/)
|
||
assert.equal(b.rechtstexte.impressumUrl, 'https://brot.example/impressum')
|
||
assert.equal(b.rechtstexte.datenschutzUrl, 'https://brot.example/datenschutz')
|
||
assert.equal(b.gewaehrleistung.bild, `https://portal.example${GEWAEHRLEISTUNG_BILD}`)
|
||
assert.match(b.gewaehrleistung.text, /GESETZLICHE GEWÄHRLEISTUNG/)
|
||
assert.equal(b.widerrufUrl, `https://portal.example/shop/${SITE_KEY}/widerruf`)
|
||
ohneInternes(b)
|
||
})
|
||
|
||
test('Kleinunternehmer-Hinweis, nur Abholung, ohne Mindestbestellwert', async () => {
|
||
const e = { ...EINSTELLUNGEN, kleinunternehmer: true, mindestbestellCent: null, versand: { ...EINSTELLUNGEN.versand, aktiv: false } }
|
||
await aufbau({ shop: { einstellungen: JSON.stringify(e) } })
|
||
const b = (await api(`/${SITE_KEY}`)).body
|
||
assert.equal(b.shop.steuerHinweisKurz, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
|
||
assert.equal(b.shop.kleinunternehmer, true)
|
||
assert.deepEqual(b.shop.lieferlaender, [])
|
||
assert.equal(b.shop.versandText, 'Nur Abholung')
|
||
assert.equal(b.shop.mindestbestellText, '')
|
||
})
|
||
|
||
test('Formular-Token der Antwort gehoert zum siteKey und ist nach der Wartezeit gueltig', async () => {
|
||
const b = (await api(`/${SITE_KEY}`)).body
|
||
assert.match(b.formToken, /^[0-9a-z]+\.[A-Za-z0-9_-]{43}$/)
|
||
const spaeter = new Date(JETZT.getTime() + 10_000)
|
||
assert.equal(pruefeFormToken(SITE_KEY, b.formToken, spaeter), 'ok')
|
||
assert.equal(pruefeFormToken(SITE_KEY, b.formToken, new Date(JETZT.getTime() + 1000)), 'zu_schnell')
|
||
assert.equal(pruefeFormToken('zzzzzzzzzzzzzzzz', b.formToken, spaeter), 'ungueltig')
|
||
assert.equal(pruefeFormToken(SITE_KEY, `${b.formToken}x`, spaeter), 'ungueltig')
|
||
assert.equal(pruefeFormToken(SITE_KEY, b.formToken, new Date(JETZT.getTime() + 3 * 3600_000)), 'abgelaufen')
|
||
config.shop.formMinSekunden = 0
|
||
assert.equal(pruefeFormToken(SITE_KEY, formToken(SITE_KEY, JETZT), JETZT), 'ok')
|
||
})
|
||
|
||
test('Reservierungen offener Bestellungen mindern den Bestand in der Antwort', async () => {
|
||
const bestand = (await api(`/${SITE_KEY}`)).body.produkte.find((p) => p.id === 'a2')
|
||
assert.equal(bestand.verfuegbar, true)
|
||
aw.seed('shopOrders', {
|
||
$id: 'o_1',
|
||
shopId: FEATURE,
|
||
status: 'offen',
|
||
ablaufAt: new Date(JETZT.getTime() + 10 * 60_000).toISOString(),
|
||
bestellung: JSON.stringify({ positionen: [{ produktId: 'a2', menge: 3 }] }),
|
||
})
|
||
const ausverkauft = (await api(`/${SITE_KEY}`)).body.produkte.find((p) => p.id === 'a2')
|
||
assert.equal(ausverkauft.verfuegbar, false)
|
||
})
|
||
|
||
test('noch keine bestaetigten Rechtstexte: rechtstexte null, Shop geschlossen', async () => {
|
||
await aufbau({ shop: { rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: null }) } })
|
||
const b = (await api(`/${SITE_KEY}`)).body
|
||
assert.equal(b.aktiv, true)
|
||
assert.equal(b.offen, false)
|
||
assert.equal(b.rechtstexte, null)
|
||
assert.ok(b.produkte.length > 0)
|
||
})
|
||
|
||
test('nicht geoeffnet (Haendler hat nicht geoeffnet): offen false', async () => {
|
||
await aufbau({ shop: { geoeffnet: false } })
|
||
const b = (await api(`/${SITE_KEY}`)).body
|
||
assert.equal(b.aktiv, true)
|
||
assert.equal(b.offen, false)
|
||
})
|
||
|
||
test('abgeschalteter Shop (disabled): nur Widerruf, keine Artikel', async () => {
|
||
await aufbau({ feature: { state: 'disabled' } })
|
||
const res = await api(`/${SITE_KEY}`)
|
||
assert.equal(res.status, 200)
|
||
assert.deepEqual(Object.keys(res.body).sort(), ['aktiv', 'formToken', 'nurWiderruf', 'offen', 'shop', 'widerrufUrl'])
|
||
assert.equal(res.body.aktiv, true)
|
||
assert.equal(res.body.offen, false)
|
||
assert.equal(res.body.nurWiderruf, true)
|
||
assert.deepEqual(Object.keys(res.body.shop).sort(), ['akzent', 'anbieter', 'name'])
|
||
assert.equal(res.body.shop.name, 'Brot & Mehr')
|
||
assert.equal(res.body.widerrufUrl, `https://portal.example/shop/${SITE_KEY}/widerruf`)
|
||
ohneInternes(res.body)
|
||
})
|
||
|
||
test('aktiv:false ohne Orakel: unbekannt, ungueltig, falscher Zustand, Modus off', async () => {
|
||
const nein = async (pfad) => {
|
||
const res = await api(pfad)
|
||
assert.equal(res.status, 200, pfad)
|
||
assert.deepEqual(res.body, { aktiv: false }, pfad)
|
||
assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*')
|
||
}
|
||
await nein('/zzzzzzzzzzzzzzzz') // unbekannt
|
||
await nein('/abc') // kein siteKey
|
||
await nein('/ABCDEFGHIJKLMNOP') // falsche Schreibweise
|
||
await aufbau({ feature: { state: 'ordered' } })
|
||
await nein(`/${SITE_KEY}`)
|
||
await aufbau({ feature: { state: 'off' } })
|
||
await nein(`/${SITE_KEY}`)
|
||
await aufbau({ shop: null }) // kein shopConfig
|
||
await nein(`/${SITE_KEY}`)
|
||
await aufbau()
|
||
config.shop.mode = 'off'
|
||
await nein(`/${SITE_KEY}`)
|
||
config.shop.mode = 'on'
|
||
assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true)
|
||
})
|
||
|
||
test('Pilotmodus: nur Besitzer auf der Pilotliste', async () => {
|
||
config.shop.mode = 'pilot'
|
||
config.shop.pilotEmails = ['andere@example.com']
|
||
assert.deepEqual((await api(`/${SITE_KEY}`)).body, { aktiv: false })
|
||
K.leereCache()
|
||
config.shop.pilotEmails = ['inhaber@brot.example']
|
||
assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true)
|
||
K.leereCache()
|
||
config.shop.pilotEmails = ['*']
|
||
assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true)
|
||
// Kunde ohne Dokument: nie freigeschaltet
|
||
await aufbau({ kunde: null })
|
||
config.shop.pilotEmails = ['inhaber@brot.example']
|
||
assert.deepEqual((await api(`/${SITE_KEY}`)).body, { aktiv: false })
|
||
// im Pilotmodus gilt dasselbe fuer die Bilder
|
||
await aufbau()
|
||
config.shop.pilotEmails = ['andere@example.com']
|
||
assert.equal((await api(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`)).status, 404)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Bilder
|
||
|
||
async function legeBildAb(id = BILD_ID, siteKey = SITE_KEY) {
|
||
const ordner = path.join(uploadDir, siteKey)
|
||
mkdirSync(ordner, { recursive: true })
|
||
const webp = await sharp({ create: { width: 4, height: 4, channels: 3, background: '#cc3300' } }).webp().toBuffer()
|
||
writeFileSync(path.join(ordner, `${id}-s.webp`), webp)
|
||
writeFileSync(path.join(ordner, `${id}-l.webp`), webp)
|
||
writeFileSync(path.join(ordner, `${id}.json`), JSON.stringify({ customerId: KUNDE, shopId: FEATURE, createdAt: JETZT.toISOString(), claimed: true, w: 4, h: 4 }))
|
||
return webp
|
||
}
|
||
|
||
test('Bild: Auslieferung mit Cache, nosniff und CORS', async () => {
|
||
const webp = await legeBildAb()
|
||
for (const g of ['s', 'l']) {
|
||
const res = await fetch(`${app.url}${BASIS}/${SITE_KEY}/bild/${BILD_ID}-${g}.webp`, { headers: { 'x-forwarded-for': ip } })
|
||
assert.equal(res.status, 200)
|
||
assert.equal(res.headers.get('content-type'), 'image/webp')
|
||
assert.equal(res.headers.get('cache-control'), 'public, max-age=86400')
|
||
assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
|
||
assert.equal(res.headers.get('access-control-allow-origin'), '*')
|
||
assert.deepEqual(Buffer.from(await res.arrayBuffer()), webp)
|
||
}
|
||
})
|
||
|
||
test('Bild: 404 bei fremdem, unbekanntem, inaktivem Artikel, schlechtem Namen und fehlender Datei', async () => {
|
||
await legeBildAb()
|
||
await legeBildAb(FREMD_BILD) // liegt im Ordner, gehoert aber zu keinem Artikel
|
||
const vier = async (pfad) => {
|
||
const res = await api(pfad)
|
||
assert.equal(res.status, 404, pfad)
|
||
assert.deepEqual(res.body, { fehler: { _: 'Nicht gefunden' } }, pfad)
|
||
assert.equal(kopfzeile(res, 'cache-control'), 'no-store')
|
||
return res
|
||
}
|
||
await vier(`/${SITE_KEY}/bild/${FREMD_BILD}-s.webp`)
|
||
await vier(`/${SITE_KEY}/bild/${BILD_ID}-m.webp`) // Groesse
|
||
await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.png`)
|
||
await vier(`/${SITE_KEY}/bild/..%2F${BILD_ID}-s.webp`)
|
||
await vier(`/${SITE_KEY}/bild/${BILD_ID.toUpperCase()}-s.webp`)
|
||
await vier(`/zzzzzzzzzzzzzzzz/bild/${BILD_ID}-s.webp`) // fremder siteKey
|
||
// Artikel inaktiv
|
||
await aufbau({ produkte: [artikel('a1', { aktiv: false }, { bilder: [BILD_ID] })] })
|
||
await legeBildAb()
|
||
await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`)
|
||
// Artikel mit Bild, aber Datei fehlt (kein Sidecar)
|
||
await aufbau()
|
||
rmSync(path.join(uploadDir, SITE_KEY), { recursive: true, force: true })
|
||
await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`)
|
||
// abgeschalteter Shop liefert keine Bilder
|
||
await legeBildAb()
|
||
await aufbau({ feature: { state: 'disabled' } })
|
||
await legeBildAb()
|
||
await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Grenzen
|
||
|
||
test('Grenze je IP: 120 je Minute, danach 429 mit Retry-After', async () => {
|
||
for (let i = 0; i < 120; i++) assert.equal(darfAnfrage(ip, JETZT), true)
|
||
const res = await api(`/${SITE_KEY}`)
|
||
assert.equal(res.status, 429)
|
||
assert.equal(kopfzeile(res, 'retry-after'), '60')
|
||
assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*')
|
||
assert.deepEqual(Object.keys(res.body), ['fehler'])
|
||
// andere IP ist nicht betroffen
|
||
assert.equal((await api(`/${SITE_KEY}`, { ip: '203.0.113.9' })).status, 200)
|
||
})
|
||
|
||
test('Grenze je Shop: 600 je Minute ueber alle IPs, unbekannte Shops zaehlen nicht', async () => {
|
||
for (let i = 0; i < 600; i++) assert.equal(zaehle(`shop:${SITE_KEY}`, 600, 60_000, JETZT), true)
|
||
const res = await api(`/${SITE_KEY}`)
|
||
assert.equal(res.status, 429)
|
||
assert.equal(kopfzeile(res, 'retry-after'), '60')
|
||
// unbekannter Shop: weiter aktiv:false statt 429 (kein Zaehler je unbekanntem Schluessel)
|
||
assert.deepEqual((await api('/zzzzzzzzzzzzzzzz')).body, { aktiv: false })
|
||
})
|
||
|
||
test('Fenster laeuft ab: nach einer Minute ist die Grenze weg', async () => {
|
||
for (let i = 0; i < 600; i++) zaehle(`shop:${SITE_KEY}`, 600, 60_000, JETZT)
|
||
assert.equal((await api(`/${SITE_KEY}`)).status, 429)
|
||
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 61_000))
|
||
assert.equal((await api(`/${SITE_KEY}`)).status, 200)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Rest
|
||
|
||
test('unbekannter Pfad: 404 ohne Rohtext', async () => {
|
||
const r1 = await api(`/${SITE_KEY}/gibtsnicht`)
|
||
assert.equal(r1.status, 404)
|
||
assert.deepEqual(r1.body, { fehler: { _: 'Nicht gefunden' } })
|
||
assert.equal(kopfzeile(r1, 'x-content-type-options'), 'nosniff')
|
||
})
|
||
|
||
test('eigener Parser: kaputter Rumpf 400, zu grosser Rumpf 413 (Router allein, ohne globalen Parser)', async () => {
|
||
const server = http.createServer(express().use('/api/public/shop/v1', router))
|
||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||
const url = `http://127.0.0.1:${server.address().port}${BASIS}/${SITE_KEY}`
|
||
try {
|
||
const kaputt = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{kaputt' })
|
||
assert.equal(kaputt.status, 400)
|
||
assert.deepEqual(await kaputt.json(), { fehler: { _: 'Ungültige Anfrage.' } })
|
||
assert.equal(kaputt.headers.get('x-content-type-options'), 'nosniff')
|
||
const gross = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ x: 'a'.repeat(20_000) }) })
|
||
assert.equal(gross.status, 413)
|
||
assert.deepEqual(await gross.json(), { fehler: { _: 'Die Anfrage ist zu groß.' } })
|
||
} finally {
|
||
server.closeAllConnections()
|
||
await new Promise((resolve) => server.close(resolve))
|
||
}
|
||
})
|
||
|
||
test('Fehler der Datenhaltung: 503 ohne Rohtext', async () => {
|
||
// Datenhaltung nicht erreichbar: der Fehler (mit Adresse) gelangt nie nach aussen
|
||
const alt = config.appwrite.endpoint
|
||
config.appwrite.endpoint = 'http://127.0.0.1:1/v1'
|
||
try {
|
||
const res = await api(`/${SITE_KEY}`)
|
||
assert.equal(res.status, 503)
|
||
assert.deepEqual(res.body, { fehler: { _: 'Gerade nicht erreichbar' } })
|
||
assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*')
|
||
} finally {
|
||
config.appwrite.endpoint = alt
|
||
}
|
||
})
|