Files
Webklar-Kundenbereich/test/shopKontext.test.js
T
ClaudeandClaude Opus 5.5 608ff61d3d Shop C6: Kontext per Projekt und siteKey, Bereitschaft und Oeffnungs-Regel
Laden von shopConfig (Einstellungen, Rechtstexte als Entwurf/bestaetigt, Stripe-Status) mit sicherem Parsen,
30-s-Speicher je siteKey mit generation-Zaehler (active und disabled), bereitschaft() mit den fuenf Punkten,
istGeoeffnet() und oeffentlicheRechtstexte().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:43:37 +00:00

359 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Shop-Kontext (C6): Laden per Projekt und siteKey, sicheres Parsen, 30-s-Speicher, Bereitschaft, Oeffnungs-Regel.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
},
datetimes: SHOP_DATETIMES,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const K = await import('../server/services/shop/kontext.js')
const { normalisiereProdukt } = await import('../server/services/shop/produkte.js')
const { speichereEinstellungen, STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_shop'
const SITE_KEY = 'abcdefghijklmnop'
const T0 = new Date('2026-10-05T12:00:00.000Z')
const nach = (s) => new Date(T0.getTime() + s * 1000)
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
shopname: 'Brot & Mehr',
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' },
}
const TEXTE = {
agb: 'AGB-Text',
widerrufsbelehrung: 'Widerruf-Text',
widerrufsformular: 'Formular-Text',
impressumUrl: 'https://brot.example/impressum',
datenschutzUrl: 'https://brot.example/datenschutz',
}
const hashFuer = (texte = TEXTE, einstellungen = EINSTELLUNGEN) => rechtstexteHash(texte, einstellungen)
const bestaetigt = (texte = TEXTE, hash = hashFuer(texte)) => ({ texte, hash, at: '2026-10-05T10:00:00.000Z', von: 'kunde@example.invalid' })
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_test',
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }),
einstellungen: JSON.stringify(EINSTELLUNGEN),
rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: bestaetigt() }),
geoeffnet: true,
version: 3,
...over,
})
const artikel = (id, over = {}, produktOver = {}) => {
const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 450, ...produktOver }, { einstellungen: { kleinunternehmer: true } })
assert.equal(r.fehler, undefined, JSON.stringify(r.fehler))
return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt), ...over }
}
async function leere() {
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'websiteProjects']) {
for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id)
}
K.leereCache()
}
async function aufbau({ feature = {}, shop = {}, produkte = [artikel('a1')] } = {}) {
await leere()
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen' })
aw.seed('portalFeatures', featureDoc(feature))
if (shop !== null) aw.seed('shopConfig', shopDoc(shop))
for (const p of produkte) aw.seed('shopProducts', p)
}
beforeEach(() => K.leereCache())
test('ladeRechtstexte: Normalfall, leer, kaputt, alte Form', () => {
const roh = JSON.stringify({ entwurf: TEXTE, bestaetigt: bestaetigt() })
const r = K.ladeRechtstexte({ rechtstexte: roh })
assert.deepEqual(r.entwurf, TEXTE)
assert.deepEqual(r.bestaetigt.texte, TEXTE)
assert.equal(r.bestaetigt.hash, hashFuer())
const leer = { entwurf: { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' }, bestaetigt: null }
for (const rohText of [undefined, null, '', '{kaputt', '[]', '"x"', '42', JSON.stringify(TEXTE), JSON.stringify({ entwurf: 5, bestaetigt: 'ja' })]) {
assert.deepEqual(K.ladeRechtstexte({ rechtstexte: rohText }), leer, String(rohText))
}
assert.deepEqual(K.ladeRechtstexte(null), leer)
// bestaetigt ohne Hash oder ohne Texte zaehlt nicht; Nicht-Text-Felder werden leer
assert.equal(K.ladeRechtstexte({ rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE } }) }).bestaetigt, null)
assert.equal(K.ladeRechtstexte({ rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { hash: 'x' } }) }).bestaetigt, null)
assert.equal(K.ladeRechtstexte({ rechtstexte: JSON.stringify({ entwurf: { ...TEXTE, agb: 5 } }) }).entwurf.agb, '')
})
test('ladeShop: geparste Felder, kaputtes JSON wird Standard', () => {
const s = K.ladeShop(shopDoc())
assert.equal(s.id, FEATURE)
assert.equal(s.siteKey, SITE_KEY)
assert.equal(s.einstellungen.shopname, 'Brot & Mehr')
assert.equal(s.stripeStatus.charges_enabled, true)
assert.equal(s.stripeAccountId, 'acct_test')
assert.equal(s.geoeffnet, true)
assert.equal(s.anfrageStatus, 'angenommen')
assert.equal(s.version, 3)
const kaputt = K.ladeShop({ $id: 'x', einstellungen: '{kaputt', rechtstexte: '{{', stripeStatus: 'nope', geoeffnet: 'ja' })
assert.equal(kaputt.einstellungen.shopname, '')
assert.equal(kaputt.einstellungen.lieferzeit, STANDARD_EINSTELLUNGEN.lieferzeit)
assert.equal(kaputt.rechtstexte.bestaetigt, null)
assert.equal(kaputt.stripeStatus, null)
assert.equal(kaputt.geoeffnet, false)
assert.equal(kaputt.version, 0)
assert.equal(K.ladeShop({ $id: 'y', stripeStatus: '[1]' }).stripeStatus, null)
})
test('ladeShopFuerProjekt: eigenes Projekt, fremdes Projekt, ohne Funktion, ohne shopConfig', async () => {
await aufbau()
const r = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(r.doc.$id, FEATURE)
assert.equal(r.shop.id, FEATURE)
assert.equal(r.project.$id, PROJEKT)
const fremd = await K.ladeShopFuerProjekt('c_anderer', PROJEKT)
assert.deepEqual(fremd, { doc: null, shop: null, project: null })
assert.deepEqual(await K.ladeShopFuerProjekt(KUNDE, 'p_gibtsnicht'), { doc: null, shop: null, project: null })
await aufbau({ shop: null })
const ohneShop = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(ohneShop.doc.$id, FEATURE)
assert.equal(ohneShop.shop, null)
assert.equal(ohneShop.project.$id, PROJEKT)
await leere()
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen' })
const ohneFunktion = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(ohneFunktion.doc, null)
assert.equal(ohneFunktion.shop, null)
assert.equal(ohneFunktion.project.$id, PROJEKT)
})
test('shopPerSiteKey: active und disabled, sonst null', async () => {
await aufbau()
const ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
assert.equal(ctx.doc.$id, FEATURE)
assert.equal(ctx.shop.id, FEATURE)
assert.equal(ctx.project.$id, PROJEKT)
await aufbau({ feature: { state: 'disabled', enabled: false } })
const aus = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
assert.equal(aus.doc.state, 'disabled', 'disabled bleibt fuer den Widerruf erreichbar')
for (const state of ['off', 'ordered', 'setup']) {
await aufbau({ feature: { state, enabled: false } })
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: T0 }), null, state)
}
await aufbau({ feature: { featureKey: 'news' } })
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: T0 }), null, 'andere Funktion')
await aufbau({ shop: null })
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: T0 }), null, 'ohne shopConfig')
await aufbau()
for (const schlecht of ['', 'ABCDEFGHIJKLMNOP', 'abcdefghijklmno', 'abcdefghijklmnop1', 'abcdefghijklmn01', null, undefined, 42, {}]) {
assert.equal(await K.shopPerSiteKey(schlecht, { now: T0 }), null, String(schlecht))
}
assert.equal(await K.shopPerSiteKey('zzzzzzzzzzzzzzzz', { now: T0 }), null, 'unbekannter siteKey')
})
test('shopPerSiteKey: 30-Sekunden-Speicher mit fester Zeit, Fehlschlaege werden auch gemerkt', async () => {
await aufbau()
const a = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
await updateDocument(config.collections.shopConfig, FEATURE, { geoeffnet: false })
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: nach(29) }), a, 'innerhalb von 30 s dasselbe Objekt')
assert.equal(a.shop.geoeffnet, true)
const b = await K.shopPerSiteKey(SITE_KEY, { now: nach(30) })
assert.notEqual(b, a)
assert.equal(b.shop.geoeffnet, false)
await aufbau({ feature: { state: 'setup', enabled: false } })
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: T0 }), null)
await updateDocument(config.collections.portalFeatures, FEATURE, { state: 'active', enabled: true })
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: nach(10) }), null, 'null ist gemerkt')
assert.ok(await K.shopPerSiteKey(SITE_KEY, { now: nach(31) }))
})
test('leereCache(siteKey) und leereCache() verwerfen den Speicher', async () => {
await aufbau()
const a = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
await updateDocument(config.collections.shopConfig, FEATURE, { geoeffnet: false })
K.leereCache('aaaaaaaaaaaaaaaa')
assert.equal(await K.shopPerSiteKey(SITE_KEY, { now: nach(1) }), a, 'anderer siteKey: nichts passiert')
K.leereCache(SITE_KEY)
assert.equal((await K.shopPerSiteKey(SITE_KEY, { now: nach(2) })).shop.geoeffnet, false)
await updateDocument(config.collections.shopConfig, FEATURE, { geoeffnet: true })
K.leereCache()
assert.equal((await K.shopPerSiteKey(SITE_KEY, { now: nach(3) })).shop.geoeffnet, true)
})
test('Speichern der Einstellungen leert den Speicher dieses Shops (Hook), andere bleiben', async () => {
await aufbau()
const a = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
assert.equal(a.shop.einstellungen.lieferzeit, '2–4 Werktage')
// zweiter Shop, bleibt im Speicher
const KEY2 = 'bcdefghijklmnopq'
aw.seed('portalFeatures', featureDoc({ $id: 'f_p2_shop', projectId: 'p2', siteKey: KEY2 }))
aw.seed('shopConfig', shopDoc({ $id: 'f_p2_shop', projectId: 'p2', siteKey: KEY2 }))
const b = await K.shopPerSiteKey(KEY2, { now: T0 })
await speichereEinstellungen(FEATURE, { ...EINSTELLUNGEN, lieferzeit: '1–2 Werktage' })
const a2 = await K.shopPerSiteKey(SITE_KEY, { now: nach(1) })
assert.notEqual(a2, a)
assert.equal(a2.shop.einstellungen.lieferzeit, '1–2 Werktage')
assert.equal(await K.shopPerSiteKey(KEY2, { now: nach(1) }), b)
})
test('Eine Abfrage, die waehrend des Leerens lief, landet nicht im Speicher', async () => {
await aufbau()
const lauf = K.shopPerSiteKey(SITE_KEY, { now: T0 })
K.leereCache() // vor dem Ende der Abfrage
const erstes = await lauf
await updateDocument(config.collections.shopConfig, FEATURE, { geoeffnet: false })
const zweites = await K.shopPerSiteKey(SITE_KEY, { now: nach(1) })
assert.notEqual(zweites, erstes)
assert.equal(zweites.shop.geoeffnet, false)
})
test('shopPerSiteKey: ungueltiger Zeitpunkt wirft', async () => {
await assert.rejects(() => K.shopPerSiteKey(SITE_KEY, { now: new Date('kaputt') }), TypeError)
})
const punkt = (r, id) => r.punkte.find((p) => p.id === id)
test('bereitschaft: alles in Ordnung', async () => {
await aufbau()
const ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
const r = await K.bereitschaft(ctx) // Artikel werden geladen
assert.deepEqual(r.punkte.map((p) => p.id), ['einbau', 'stripe', 'einstellungen', 'rechtstexte', 'artikel'])
assert.equal(r.bereit, true)
assert.ok(r.punkte.every((p) => p.ok && typeof p.text === 'string' && p.text !== ''))
assert.equal(punkt(r, 'einbau').text, 'Der Shop ist eingebaut.')
assert.equal(await K.istGeoeffnet(ctx), true)
assert.equal(await K.istGeoeffnet(ctx, { produkte: [artikel('a1')] }), true)
})
test('bereitschaft: einbau', async () => {
await aufbau({ feature: { state: 'setup', enabled: false } })
const { doc, shop } = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
const r = await K.bereitschaft({ doc, shop })
assert.equal(punkt(r, 'einbau').ok, false)
assert.equal(punkt(r, 'einbau').text, 'Wir bauen den Shop gerade in Deine Website ein.')
assert.equal(r.bereit, false)
assert.equal(await K.istGeoeffnet({ doc, shop }), false)
})
test('bereitschaft: stripe', async () => {
for (const stripeStatus of [null, '', '{kaputt', JSON.stringify({ charges_enabled: false }), JSON.stringify({ charges_enabled: 'true' })]) {
await aufbau({ shop: { stripeStatus } })
const { doc, shop } = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
const r = await K.bereitschaft({ doc, shop })
assert.equal(punkt(r, 'stripe').ok, false, String(stripeStatus))
assert.match(punkt(r, 'stripe').text, /^Bitte /)
assert.equal(await K.istGeoeffnet({ doc, shop }), false)
}
})
test('bereitschaft: einstellungen', async () => {
await aufbau({ shop: { einstellungen: JSON.stringify({ ...EINSTELLUNGEN, anbieter: { ...EINSTELLUNGEN.anbieter, email: '' } }) } })
const { doc, shop } = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
const r = await K.bereitschaft({ doc, shop })
assert.equal(punkt(r, 'einstellungen').ok, false)
assert.equal(await K.istGeoeffnet({ doc, shop }), false)
await aufbau({ shop: { einstellungen: '{kaputt' } })
const k = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(punkt(await K.bereitschaft(k), 'einstellungen').ok, false)
})
test('bereitschaft und Oeffnung: Rechtstexte offen, geaendert, bestaetigt', async () => {
// nie bestaetigt
await aufbau({ shop: { rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: null }) } })
let c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
let r = await K.bereitschaft(c)
assert.equal(punkt(r, 'rechtstexte').ok, false)
assert.equal(punkt(r, 'rechtstexte').text, 'Bitte bestätige Deine Rechtstexte.')
assert.equal(await K.istGeoeffnet(c), false, 'nie bestaetigt => zu')
assert.equal(K.oeffentlicheRechtstexte(c), null)
// Entwurf unvollstaendig
await aufbau({ shop: { rechtstexte: '' } })
c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
r = await K.bereitschaft(c)
assert.equal(punkt(r, 'rechtstexte').ok, false)
assert.match(punkt(r, 'rechtstexte').text, /^Bitte ergänze Deine Rechtstexte/)
// bestaetigt, Entwurf danach geaendert
const neu = { ...TEXTE, agb: 'Neue AGB' }
await aufbau({ shop: { rechtstexte: JSON.stringify({ entwurf: neu, bestaetigt: bestaetigt() }) } })
c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
r = await K.bereitschaft(c)
assert.equal(punkt(r, 'rechtstexte').ok, false)
assert.equal(punkt(r, 'rechtstexte').text, 'Bitte bestätige Deine geänderten Rechtstexte.')
assert.equal(r.bereit, false)
assert.equal(await K.istGeoeffnet(c), true, 'frueher bestaetigt => bleibt offen')
assert.deepEqual(K.oeffentlicheRechtstexte(c), TEXTE, 'Kaeufer sehen die bestaetigte Fassung')
// Hash passt zum Entwurf
await aufbau({ shop: { rechtstexte: JSON.stringify({ entwurf: neu, bestaetigt: bestaetigt(neu) }) } })
c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(punkt(await K.bereitschaft(c), 'rechtstexte').ok, true)
// Hash passt nach geaenderten Einstellungen (Versand/Zahlung fliessen ein) nicht mehr
await aufbau({ shop: { einstellungen: JSON.stringify({ ...EINSTELLUNGEN, zahlungsarten: ['card', 'klarna'] }) } })
c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(punkt(await K.bereitschaft(c), 'rechtstexte').ok, false)
assert.equal(await K.istGeoeffnet(c), true)
})
test('bereitschaft und Oeffnung: Artikel', async () => {
const lauf = async (produkte) => {
await aufbau({ produkte })
const c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
return { r: await K.bereitschaft(c), offen: await K.istGeoeffnet(c) }
}
let e = await lauf([])
assert.equal(punkt(e.r, 'artikel').ok, false)
assert.equal(punkt(e.r, 'artikel').text, 'Bitte lege mindestens einen aktiven, lieferbaren Artikel an.')
assert.equal(e.offen, false)
e = await lauf([artikel('a1', { aktiv: false })])
assert.equal(punkt(e.r, 'artikel').ok, false, 'nur inaktive')
e = await lauf([artikel('a1', {}, { bestand: 0 })])
assert.equal(punkt(e.r, 'artikel').ok, false, 'ausverkauft')
assert.equal(e.offen, false)
e = await lauf([artikel('a1', { produkt: '{kaputt' })])
assert.equal(punkt(e.r, 'artikel').ok, false, 'unlesbar')
e = await lauf([artikel('a1', { aktiv: false }), artikel('a2', {}, { bestand: 5 })])
assert.equal(punkt(e.r, 'artikel').ok, true)
assert.equal(e.offen, true)
})
test('istGeoeffnet: Haendler hat geschlossen, Funktion disabled, uebergebene Artikel', async () => {
await aufbau({ shop: { geoeffnet: false } })
let c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(await K.istGeoeffnet(c), false)
assert.equal((await K.bereitschaft(c)).bereit, true, 'bereit, aber nicht geoeffnet')
await aufbau({ feature: { state: 'disabled', enabled: false } })
c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(await K.istGeoeffnet(c), false)
await aufbau()
c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT)
assert.equal(await K.istGeoeffnet(c, { produkte: [] }), false, 'uebergebene (leere) Liste gilt, es wird nicht nachgeladen')
assert.equal((await K.bereitschaft(c, { produkte: [] })).punkte.find((p) => p.id === 'artikel').ok, false)
})