diff --git a/server/services/shop/kontext.js b/server/services/shop/kontext.js new file mode 100644 index 0000000..2e24f9e --- /dev/null +++ b/server/services/shop/kontext.js @@ -0,0 +1,257 @@ +import { config } from '../../config.js' +import { getDocument, listDocuments, Query } from '../appwriteAdmin.js' +import { getFeatureDoc } from '../siteFeatures.js' +import { istVollstaendig, ladeEinstellungen, nachSpeichern } from './einstellungen.js' +import { listeProdukte, produktOeffentlich } from './produkte.js' +import { rechtstexteHash, vollstaendig } from './rechtstexte.js' + +/** + * Kontext eines Shops (Plan 2026-10-05-webshop.md, Task C6; Spezifikation 4.6) und seine Bereitschaft zum Verkaufen. + * + * ctx = { doc, shop, project } + * doc portalFeatures-Dokument (featureKey 'shop') + * shop geparstes shopConfig-Dokument (ladeShop) oder null (noch keines angelegt) + * project websiteProjects-Dokument oder null + * Ein ctx aus shopPerSiteKey ist nur zum Lesen: innerhalb von 30 Sekunden kommt dasselbe Objekt zurueck. + * + * shopConfig.rechtstexte ist ein JSON-Text { entwurf: {agb, widerrufsbelehrung, widerrufsformular, impressumUrl, datenschutzUrl}, + * bestaetigt: null | { texte: {dieselben fuenf}, hash, at, von } }. Der Entwurf gehoert dem Haendler, die Kaeufer sehen + * immer die zuletzt bestaetigte Fassung (oeffentlicheRechtstexte). Kaputt oder alt => leerer Entwurf, nichts bestaetigt. + */ + +const FUNKTION = 'shop' +const SITE_KEY_RE = /^[a-z2-7]{16}$/ +const CACHE_MS = 30 * 1000 +const CACHE_MAX = 10000 +const TEXT_FELDER = ['agb', 'widerrufsbelehrung', 'widerrufsformular', 'impressumUrl', 'datenschutzUrl'] + +const cache = new Map() +/** Wird mit jedem Leeren erhoeht: eine Abfrage, die davor begann, darf ihr Ergebnis nicht mehr merken. */ +let generation = 0 + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */ +const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found' + +async function holeOderNull(collectionId, id) { + if (typeof id !== 'string' || id === '') return null + try { + return await getDocument(collectionId, id) + } catch (err) { + if (dokumentFehlt(err)) return null + throw err + } +} + +const leereTexte = () => Object.fromEntries(TEXT_FELDER.map((k) => [k, ''])) +const textFelder = (roh) => Object.fromEntries(TEXT_FELDER.map((k) => [k, istObjekt(roh) && Object.hasOwn(roh, k) && typeof roh[k] === 'string' ? roh[k] : ''])) + +/** + * Rechtstexte aus einem shopConfig-Dokument: { entwurf, bestaetigt }. Fehlt der JSON-Text, ist er kaputt oder hat er eine + * andere Form, ist das Ergebnis { entwurf: (leer), bestaetigt: null }. bestaetigt braucht texte (Objekt) und einen hash (Text). + */ +export function ladeRechtstexte(shopDoc) { + let roh = null + try { + roh = typeof shopDoc?.rechtstexte === 'string' && shopDoc.rechtstexte !== '' ? JSON.parse(shopDoc.rechtstexte) : null + } catch { + roh = null + } + if (!istObjekt(roh)) return { entwurf: leereTexte(), bestaetigt: null } + const b = Object.hasOwn(roh, 'bestaetigt') ? roh.bestaetigt : null + const bestaetigt = + istObjekt(b) && istObjekt(b.texte) && typeof b.hash === 'string' && b.hash !== '' + ? { texte: textFelder(b.texte), hash: b.hash, at: typeof b.at === 'string' ? b.at : '', von: typeof b.von === 'string' ? b.von : '' } + : null + return { entwurf: textFelder(Object.hasOwn(roh, 'entwurf') ? roh.entwurf : null), bestaetigt } +} + +function ladeStripeStatus(shopDoc) { + try { + const s = typeof shopDoc?.stripeStatus === 'string' && shopDoc.stripeStatus !== '' ? JSON.parse(shopDoc.stripeStatus) : null + return istObjekt(s) ? s : null + } catch { + return null + } +} + +/** Geparster Shop aus einem shopConfig-Dokument. */ +export function ladeShop(shopDoc) { + return { + id: shopDoc.$id, + customerId: shopDoc.customerId ?? '', + projectId: shopDoc.projectId ?? '', + siteKey: shopDoc.siteKey ?? '', + einstellungen: ladeEinstellungen(shopDoc), + rechtstexte: ladeRechtstexte(shopDoc), + stripeAccountId: typeof shopDoc.stripeAccountId === 'string' ? shopDoc.stripeAccountId : '', + stripeStatus: ladeStripeStatus(shopDoc), + geoeffnet: shopDoc.geoeffnet === true, + anfrageStatus: typeof shopDoc.anfrageStatus === 'string' ? shopDoc.anfrageStatus : '', + version: Number.isInteger(shopDoc.version) && shopDoc.version > 0 ? shopDoc.version : 0, + } +} + +/** + * Kontext fuer das Portal: portalFeatures-Dokument des Projekts (Zustand egal), shopConfig dazu und das Projekt. + * doc null: keine Funktion; shop null: noch kein shopConfig-Dokument; project null: das Projekt gibt es nicht oder es gehoert + * einem anderen Kunden. + * @returns {Promise<{ doc: object|null, shop: object|null, project: object|null }>} + */ +export async function ladeShopFuerProjekt(customerId, projectId) { + const projekt = await holeOderNull(config.collections.websiteProjects, projectId) + const project = projekt && customerId && projekt.customerId === customerId ? projekt : null + const doc = project ? await getFeatureDoc(customerId, projectId, FUNKTION) : null + const shopDoc = doc ? await holeOderNull(config.collections.shopConfig, doc.$id) : null + return { doc, shop: shopDoc ? ladeShop(shopDoc) : null, project } +} + +/** + * Kontext fuer die oeffentliche Schnittstelle: Funktion 'shop' mit diesem siteKey im Zustand active oder disabled (disabled, + * weil Kaeufer den Widerruf weiter erklaeren koennen muessen) samt shopConfig und Projekt. Treffer und Fehlschlaege werden 30 Sekunden + * je siteKey gemerkt; leereCache und ein Speichern der Einstellungen leeren den Speicher. + * @param {string} siteKey + * @param {{ now?: Date }} [opt] now: Zeitpunkt fuer den Speicher (Tests) + * @returns {Promise<{ doc: object, shop: object, project: object|null }|null>} null bei ungueltigem oder unbekanntem siteKey, + * anderem Zustand als active/disabled und fehlendem shopConfig + */ +export async function shopPerSiteKey(siteKey, { now = new Date() } = {}) { + if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return null + const nowMs = now instanceof Date ? now.getTime() : Number(now) + if (!Number.isFinite(nowMs)) throw new TypeError('shopPerSiteKey: now ist kein gueltiger Zeitpunkt') + const gemerkt = cache.get(siteKey) + if (gemerkt && gemerkt.bis > nowMs) return gemerkt.ctx + + const stand = generation + const [doc] = await listDocuments(config.collections.portalFeatures, [ + Query.equal('siteKey', siteKey), + Query.equal('featureKey', FUNKTION), + Query.equal('state', ['active', 'disabled']), + Query.limit(1), + ]) + let ctx = null + if (doc && ['active', 'disabled'].includes(doc.state)) { + const [shopDoc, project] = await Promise.all([ + holeOderNull(config.collections.shopConfig, doc.$id), + doc.projectId ? holeOderNull(config.collections.websiteProjects, doc.projectId) : null, + ]) + if (shopDoc) ctx = { doc, shop: ladeShop(shopDoc), project } + } + if (stand === generation) merke(siteKey, ctx, nowMs) + return ctx +} + +function merke(siteKey, ctx, nowMs) { + if (cache.size >= CACHE_MAX) { + for (const [key, eintrag] of cache) if (eintrag.bis <= nowMs) cache.delete(key) + if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value) + } + cache.set(siteKey, { bis: nowMs + CACHE_MS, ctx }) +} + +/** Leert den Speicher von shopPerSiteKey: fuer einen siteKey oder (ohne Angabe) ganz. Laufende Abfragen merken ihr Ergebnis nicht mehr. */ +export function leereCache(siteKey) { + generation++ + if (siteKey === undefined) cache.clear() + else cache.delete(siteKey) +} + +// Nach dem Speichern der Einstellungen (Hook mit der Shop-ID) alle Eintraege dieses Shops verwerfen +nachSpeichern((shopId) => { + generation++ + for (const [key, eintrag] of cache) if (eintrag.ctx?.shop?.id === shopId) cache.delete(key) +}) + +// --------------------------------------------------------------------------------------------------------------------- +// Bereitschaft + +/** Mindestens ein aktiver, lesbarer und lieferbarer Artikel. */ +function hatArtikel(produkte, shop) { + return (Array.isArray(produkte) ? produkte : []).some((p) => { + if (p?.aktiv === false) return false + const o = produktOeffentlich(p, { einstellungen: shop.einstellungen, siteKey: shop.siteKey }) + return o !== null && o.verfuegbar === true + }) +} + +async function produkteFuer(ctx, produkte) { + return Array.isArray(produkte) ? produkte : listeProdukte(ctx.shop.id) +} + +/** Stand der Rechtstexte: 'bestaetigt' (Hash passt), 'geaendert' (frueher bestaetigt, Entwurf weicht ab) oder 'offen'. */ +function rechtstexteStand(shop) { + const { entwurf, bestaetigt } = shop.rechtstexte + if (!bestaetigt) return 'offen' + return bestaetigt.hash === rechtstexteHash(entwurf, shop.einstellungen) ? 'bestaetigt' : 'geaendert' +} + +/** + * Die fuenf Punkte, die vor dem Oeffnen stimmen muessen, in dieser Reihenfolge: einbau, stripe, einstellungen, rechtstexte, artikel. + * @param {{ doc: object, shop: object }} ctx + * @param {{ produkte?: object[] }} [opt] bereits geladene Artikel-Dokumente; ohne Angabe werden sie geladen + * @returns {Promise<{ bereit: boolean, punkte: Array<{ id: string, ok: boolean, text: string }> }>} + */ +export async function bereitschaft(ctx, { produkte } = {}) { + const { doc, shop } = ctx + const liste = await produkteFuer(ctx, produkte) + const stand = rechtstexteStand(shop) + const punkte = [ + { + id: 'einbau', + ok: doc?.state === 'active', + text: doc?.state === 'active' ? 'Der Shop ist eingebaut.' : 'Wir bauen den Shop gerade in Deine Website ein.', + }, + { + id: 'stripe', + ok: shop.stripeStatus?.charges_enabled === true, + text: + shop.stripeStatus?.charges_enabled === true + ? 'Dein Zahlungskonto bei Stripe ist bereit.' + : 'Bitte richte Dein Zahlungskonto bei Stripe ein, damit Du Zahlungen empfangen kannst.', + }, + { + id: 'einstellungen', + ok: istVollstaendig(shop.einstellungen), + text: istVollstaendig(shop.einstellungen) + ? 'Deine Shop-Angaben sind vollständig.' + : 'Bitte vervollständige Deine Shop-Angaben (Shopname, Anbieter mit Anschrift und E-Mail, Lieferzeit).', + }, + { + id: 'rechtstexte', + ok: stand === 'bestaetigt', + text: + stand === 'bestaetigt' + ? 'Deine Rechtstexte sind bestätigt.' + : stand === 'geaendert' + ? 'Bitte bestätige Deine geänderten Rechtstexte.' + : vollstaendig(shop.rechtstexte.entwurf) + ? 'Bitte bestätige Deine Rechtstexte.' + : 'Bitte ergänze Deine Rechtstexte (AGB, Widerrufsbelehrung, Widerrufsformular, Impressum und Datenschutz) und bestätige sie.', + }, + { + id: 'artikel', + ok: hatArtikel(liste, shop), + text: hatArtikel(liste, shop) ? 'Du hast mindestens einen lieferbaren Artikel.' : 'Bitte lege mindestens einen aktiven, lieferbaren Artikel an.', + }, + ] + return { bereit: punkte.every((p) => p.ok), punkte } +} + +/** + * Verkauft der Shop gerade? Geoeffnet vom Haendler, Funktion active, Stripe bereit, Angaben vollstaendig, mindestens ein lieferbarer + * Artikel und die Rechtstexte irgendwann bestaetigt (auch wenn der Entwurf danach geaendert wurde: der Shop bleibt mit der zuletzt + * bestaetigten Fassung offen, Spezifikation 4.6). + * @returns {Promise} + */ +export async function istGeoeffnet(ctx, { produkte } = {}) { + const { doc, shop } = ctx + if (shop.geoeffnet !== true || doc?.state !== 'active' || shop.stripeStatus?.charges_enabled !== true) return false + if (!istVollstaendig(shop.einstellungen) || !shop.rechtstexte.bestaetigt) return false + return hatArtikel(await produkteFuer(ctx, produkte), shop) +} + +/** Die den Kaeufern gezeigten Rechtstexte (zuletzt bestaetigte Fassung) oder null, wenn noch nie bestaetigt. */ +export function oeffentlicheRechtstexte(ctx) { + return ctx?.shop?.rechtstexte?.bestaetigt?.texte ?? null +} diff --git a/test/shopKontext.test.js b/test/shopKontext.test.js new file mode 100644 index 0000000..97be98b --- /dev/null +++ b/test/shopKontext.test.js @@ -0,0 +1,358 @@ +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) +})