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)), kleinunternehmer: true, lieferzeit: '2–4 Werktage', 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 I-4: Steuerart nicht gewählt (null) oder Lieferzeit leer - Punkt einstellungen offen, Shop bleibt zu', async () => { for (const luecke of [{ kleinunternehmer: null }, { lieferzeit: '' }]) { await aufbau({ shop: { einstellungen: JSON.stringify({ ...EINSTELLUNGEN, ...luecke }) } }) const c = await K.ladeShopFuerProjekt(KUNDE, PROJEKT) const r = await K.bereitschaft(c) assert.equal(punkt(r, 'einstellungen').ok, false, JSON.stringify(luecke)) assert.match(punkt(r, 'einstellungen').text, /Steuerart/) assert.equal(r.bereit, false) assert.equal(await K.istGeoeffnet(c), false) } await aufbau({ shop: { einstellungen: JSON.stringify({ ...EINSTELLUNGEN, kleinunternehmer: false }) } }) assert.equal(punkt(await K.bereitschaft(await K.ladeShopFuerProjekt(KUNDE, PROJEKT)), 'einstellungen').ok, true) }) 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) })