import { test, after, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { schemaKeys } from './support/schema.js' import { useTestEnv } from './support/testEnv.js' import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' // Oeffentliche Antwort der Oeffnungszeiten (server/services/zeiten/oeffentlich.js, Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, // Abschnitt 4.1): baueOeffentlicheAntwort ist rein, oeffentlicheZeitenAntwort prueft Schalter und Schluessel, holt Funktion, Kunde und // Zeiten aus dem Appwrite-Nachbau und legt die Antwort 30 Sekunden im Speicher ab. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), oeffnungszeiten: schemaKeys('oeffnungszeiten'), zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), }, datetimes: ZEITEN_DATETIMES, defaultLimit: 25, }) after(() => aw.close()) 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 { zeitenCache, resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') const { speichereZeiten, speichereTeam } = await import('../server/services/zeiten/speicher.js') const { baueOeffentlicheAntwort, oeffentlicheZeitenAntwort } = await import('../server/services/zeiten/oeffentlich.js') const JETZT = new Date('2026-10-05T10:00:00.000Z') // Berlin: Montag 2026-10-05 12:00 const SITE_KEY = 'abcdabcdabcdabcd' const KUNDE = 'c_kunde01' const PROJEKT = 'p_projekt01' const FEATURE_ID = 'f_p_projekt01_zeiten' const P_STANDARD = 'p_aaaaaaa1' const P_KUECHE = 'p_aaaaaaa2' const ADRESSE = 'Hauptstraße 1, 67722 Winnweiler' const STARTTEXT = 'Hallo, ich habe eine Frage & möchte einen Termin!' const MODUS_VORHER = config.zeiten.mode const FEATURE = { $id: FEATURE_ID, customerId: KUNDE, projectId: PROJEKT, siteKey: SITE_KEY, featureKey: 'zeiten', state: 'active', enabled: true } const tage = (woche = {}) => { const t = {} for (let d = 1; d <= 7; d += 1) t[d] = woche[d] ? { modus: 'zeiten', fenster: [woche[d]] } : { modus: 'zu', fenster: [] } return t } const eingabe = (extra = {}) => ({ land: 'RP', plaene: [ { id: P_STANDARD, kurz: 'standard', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: ['09:00', '18:00'], 4: ['09:00', '18:00'], 5: ['09:00', '18:00'], 6: ['09:00', '12:00'] }) }, { id: P_KUECHE, kurz: 'kueche', name: 'Küche', haupt: false, tage: tage({ 1: ['11:00', '14:00'] }) }, ], feiertage: {}, ausnahmen: [{ id: 'x_urlaub01', art: 'urlaub', von: '2026-10-12', bis: '2026-10-16', text: 'Betriebsurlaub', plan: null }], hinweise: [ { id: 'h_dauer001', art: 'info', text: 'Wir sind für Euch da.', knopf: null }, // ohne von/bis: ohne Ende { id: 'h_aktion01', art: 'aktion', text: 'Herbstaktion', knopf: { art: 'link', ziel: 'https://bäckerei.example/aktion', text: 'Zur Aktion' }, von: '2026-10-10', bis: '2026-10-20' }, { id: 'h_spaet001', art: 'info', text: 'Weihnachtsöffnung', knopf: null, von: '2026-12-01', bis: '2026-12-24' }, // beginnt erst in 57 Tagen { id: 'h_vorbei01', art: 'info', text: 'Alt', knopf: null, von: '2026-09-01', bis: '2026-09-02' }, ], kontakt: { an: true, telefon: '0171 1234567', adresse: ADRESSE, whatsapp: { nummer: '+49 171 1234567', starttext: STARTTEXT, bestaetigtAt: '2026-10-01T08:00:00.000Z' }, }, ...extra, }) /** Gespeicherte, normalisierte Daten wie in Appwrite (Weg ueber speichereZeiten, wie im Betrieb). */ async function gespeicherteDaten(extra = {}) { return (await speichereZeiten({ feature: FEATURE, eingabe: eingabe(extra), version: 0, by: 'kunde', now: JETZT })).daten } const TEAM = { navSelektor: '#kopf', kontaktleisteAus: false, hinweisleisteAus: true } const FIRMA = { name: 'Bäckerei Sonnenschein' } // der Aufrufer liefert den Namen, die Adresse kommt aus den Kontaktangaben der Daten const baue = (daten, extra = {}) => baueOeffentlicheAntwort({ daten, team: TEAM, firma: FIRMA, now: JETZT, ...extra }) /** Funktion, Kunde und Projekt des Normalfalls in den Nachbau legen. */ function legeAn({ feature = {}, kunde = { name: 'Frau Sonne', companyName: 'Bäckerei Sonnenschein' }, projekt = { projectName: 'Sonnenschein-Website' } } = {}) { aw.seed('portalFeatures', { ...FEATURE, ...feature }) if (kunde) aw.seed('customers', { $id: KUNDE, ...kunde }) if (projekt) aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, ...projekt }) } const listen = () => aw.listCalls('portalFeatures').length beforeEach(async () => { for (const collection of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects']) { for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id) } config.zeiten.mode = 'on' resetZeitenCacheForTests() aw.resetListCalls() mock.method(console, 'error', () => {}) mock.method(console, 'warn', () => {}) }) afterEach(() => { config.zeiten.mode = MODUS_VORHER mock.restoreAll() }) /** Alle Schluessel und Zeichenketten einer Antwort (rekursiv). */ function durchsuche(wert, schluessel = new Set(), werte = []) { if (Array.isArray(wert)) wert.forEach((e) => durchsuche(e, schluessel, werte)) else if (wert && typeof wert === 'object') { for (const [k, v] of Object.entries(wert)) { schluessel.add(k) durchsuche(v, schluessel, werte) } } else if (typeof wert === 'string') werte.push(wert) return { schluessel, werte } } // ---------------------------------------------------------------- baueOeffentlicheAntwort (rein) test('Form: aktiv, stand, plaene, hinweise, kontakt, jsonld, firma, team (nach Spec 4.1)', async () => { const a = baue(await gespeicherteDaten()) assert.deepEqual(Object.keys(a), ['aktiv', 'stand', 'plaene', 'hinweise', 'kontakt', 'jsonld', 'firma', 'team']) assert.equal(a.aktiv, true) assert.equal(a.stand, JETZT.toISOString()) assert.deepEqual(a.firma, { name: 'Bäckerei Sonnenschein', adresse: ADRESSE }) assert.deepEqual(a.team, TEAM) assert.equal(JSON.parse(JSON.stringify(a)).plaene.length, 2, 'ist JSON-tauglich') }) test('Plaene: kurz, name, haupt, woche (7 Tage), tage (60 Tage ab heute), wechsel (ab, tag, offen, text, kurz)', async () => { const a = baue(await gespeicherteDaten()) const [standard, kueche] = a.plaene assert.deepEqual(Object.keys(standard), ['kurz', 'name', 'haupt', 'woche', 'tage', 'wechsel']) assert.deepEqual([standard.kurz, standard.name, standard.haupt], ['standard', 'Standard', true]) assert.deepEqual([kueche.kurz, kueche.name, kueche.haupt], ['kueche', 'Küche', false]) assert.deepEqual(standard.woche.map((t) => t.tag), [1, 2, 3, 4, 5, 6, 7]) assert.deepEqual(standard.woche[0], { tag: 1, modus: 'zeiten', fenster: [['09:00', '18:00']] }) assert.deepEqual(standard.woche[6], { tag: 7, modus: 'zu', fenster: [] }) assert.deepEqual(kueche.woche[0].fenster, [['11:00', '14:00']], 'jeder Plan hat seine eigene Woche') assert.equal(standard.tage.length, 60) assert.equal(standard.tage[0].datum, '2026-10-05', 'heute (Berliner Tag)') assert.equal(standard.tage[59].datum, '2026-12-03') assert.deepEqual(Object.keys(standard.tage[0]), ['datum', 'art', 'name', 'modus', 'fenster']) const urlaub = standard.tage.find((t) => t.datum === '2026-10-12') assert.deepEqual([urlaub.art, urlaub.name, urlaub.modus], ['urlaub', 'Betriebsurlaub', 'zu']) assert.equal(standard.tage.find((t) => t.datum === '2026-10-03'), undefined, 'gestern ist nicht dabei') assert.equal(standard.tage.find((t) => t.datum === '2026-11-01')?.art, 'feiertag', 'Allerheiligen (RP)') assert.ok(standard.wechsel.length >= 8, 'mindestens je Tag ein Wechsel') assert.deepEqual(Object.keys(standard.wechsel[0]), ['ab', 'tag', 'offen', 'text', 'kurz']) assert.equal(typeof standard.wechsel[0].ab, 'number') assert.equal(standard.wechsel[0].offen, true, 'Montag 12:00 ist offen') assert.match(standard.wechsel[0].text, /geöffnet/i) assert.ok(standard.wechsel.every((w, i, l) => i === 0 || w.ab > l[i - 1].ab), 'aufsteigend') assert.ok(standard.wechsel.at(-1).ab < JETZT.getTime() + 8 * 24 * 3600 * 1000 + 3600 * 1000, 'hoechstens 8 Tage') assert.notDeepEqual(kueche.wechsel, standard.wechsel, 'Wechsel je Plan') }) test('wechsel[].tag ist der Berliner Kalendertag bei ab (das Skript rechnet keine Zeitzonen)', async () => { const daten = await gespeicherteDaten() const berlinTag = (ms) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(ms)) // 22:30 UTC am 4.10. = 00:30 Berliner Zeit am 5.10.: in UTC waere es noch der 4. const nachtUtc = new Date('2026-10-04T22:30:00.000Z') const a = baue(daten, { now: nachtUtc }) for (const plan of a.plaene) { assert.equal(plan.wechsel[0].tag, '2026-10-05', `Berliner Tag zu Beginn (${plan.kurz})`) assert.equal(plan.tage[0].datum, plan.wechsel[0].tag, 'derselbe „heute“ wie tage[0]') for (const w of plan.wechsel) assert.equal(w.tag, berlinTag(w.ab), `tag passt zu ab=${w.ab}`) } const standard = a.plaene[0] assert.ok(standard.wechsel.length > 1) assert.equal(new Set(standard.wechsel.map((w) => w.tag)).size > 1, true, 'spaetere Wechsel tragen ihren eigenen Tag') assert.ok(!JSON.stringify(a).includes('x_urlaub01'), 'weiterhin keine internen IDs') }) test('Hinweise: gueltige und solche mit Beginn in 8 Tagen, ohne IDs; offene Enden bleiben Zahlen', async () => { const a = baue(await gespeicherteDaten()) assert.deepEqual(a.hinweise.map((h) => h.text), ['Wir sind für Euch da.', 'Herbstaktion'], 'weder der ferne noch der vergangene') assert.deepEqual(Object.keys(a.hinweise[0]), ['art', 'text', 'knopf', 'ab', 'bis', 'rang']) assert.deepEqual([a.hinweise[0].ab, a.hinweise[0].bis, a.hinweise[0].rang, a.hinweise[0].knopf], [0, 8.64e15, 1, null]) const aktion = a.hinweise[1] assert.equal(aktion.art, 'aktion') assert.equal(aktion.rang, 2) assert.deepEqual(aktion.knopf, { art: 'link', ziel: 'https://xn--bckerei-5wa.example/aktion', text: 'Zur Aktion' }) assert.equal(aktion.ab, Date.parse('2026-10-09T22:00:00.000Z'), 'Beginn 10.10. 00:00 Berliner Zeit (Sommerzeit)') assert.equal(aktion.bis, Date.parse('2026-10-20T22:00:00.000Z'), 'Ende 20.10. 24:00 = 21.10. 00:00') assert.equal(JSON.parse(JSON.stringify(a.hinweise))[0].bis, 8.64e15) }) test('JSON-LD: Wochenzeiten und Sonderzeiten des Hauptplans samt neuem Block', async () => { const a = baue(await gespeicherteDaten()) const { openingHoursSpecification: woche, specialOpeningHoursSpecification: sonder, neuerBlock } = a.jsonld assert.deepEqual(woche.find((e) => e.dayOfWeek === 'Monday'), { '@type': 'OpeningHoursSpecification', dayOfWeek: 'Monday', opens: '09:00', closes: '18:00' }) assert.equal(woche.find((e) => e.dayOfWeek === 'Sunday'), undefined) assert.deepEqual( sonder.find((e) => e.validFrom === '2026-10-12'), { '@type': 'OpeningHoursSpecification', validFrom: '2026-10-12', validThrough: '2026-10-16', opens: '00:00', closes: '00:00' }, ) assert.equal(neuerBlock['@context'], 'https://schema.org', 'das https-Literal liefert der Server, das Skript hat keins') assert.equal(neuerBlock['@type'], 'LocalBusiness') assert.deepEqual(neuerBlock.openingHoursSpecification, woche) assert.ok(!('name' in neuerBlock) && !('address' in neuerBlock) && !('url' in neuerBlock)) }) test('JSON-LD folgt dem Hauptplan, auch wenn er nicht der erste ist', async () => { const daten = await gespeicherteDaten() daten.plaene.reverse() const a = baue(daten) assert.deepEqual(a.plaene.map((p) => p.kurz), ['kueche', 'standard']) assert.equal(a.jsonld.openingHoursSpecification.find((e) => e.dayOfWeek === 'Monday').closes, '18:00') }) test('vor dem ersten Speichern: aktiv true, plaene leer, kein jsonld', () => { for (const daten of [null, undefined, {}, { plaene: [] }, 'kaputt']) { const a = baue(daten) assert.equal(a.aktiv, true) assert.deepEqual(a.plaene, []) assert.deepEqual(a.hinweise, []) assert.equal(a.kontakt, null) assert.equal(a.jsonld, null, 'null statt leerer Listen: ein leeres JSON-LD wuerde vorhandenes im Browser ueberschreiben') assert.deepEqual(a.firma, { name: 'Bäckerei Sonnenschein', adresse: null }) assert.deepEqual(a.team, TEAM, 'Team-Einstellungen gelten auch ohne Zeiten') } }) test('ohne Hauptplan kein JSON-LD, die Plaene bleiben', async () => { const daten = await gespeicherteDaten() for (const p of daten.plaene) p.haupt = false const a = baue(daten) assert.equal(a.jsonld, null) assert.deepEqual(a.plaene.map((p) => p.haupt), [false, false]) }) test('Kontakt-Leiste mit Teildaten', () => { const kontakt = (extra) => ({ an: true, telefon: '', adresse: '', whatsapp: null, ...extra }) const nur = baue({ plaene: [], kontakt: kontakt({ telefon: '491711234567' }) }).kontakt assert.deepEqual(nur, { telefon: '491711234567', routeUrl: null, whatsappUrl: null }) const adresse = baue({ plaene: [], kontakt: kontakt({ adresse: ADRESSE }) }).kontakt assert.deepEqual(adresse, { telefon: null, routeUrl: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ADRESSE)}`, whatsappUrl: null }) const leer = baue({ plaene: [], kontakt: kontakt({}) }).kontakt assert.equal(leer, null, 'an: true ohne Angaben ergibt keine Leiste') assert.equal(baue({ plaene: [], kontakt: kontakt({ telefon: '491711234567', an: false }) }).kontakt, null, 'ausgeschaltet') assert.equal(baue({ plaene: [], kontakt: kontakt({ telefon: ' ', adresse: ' ' }) }).kontakt, null, 'nur Leerraum') }) test('routeUrl und whatsappUrl', () => { const daten = { plaene: [], kontakt: { an: true, telefon: '491711234567', adresse: ADRESSE, whatsapp: { nummer: '491711234567', starttext: STARTTEXT, bestaetigtAt: '2026-10-01T08:00:00.000Z' } }, } const k = baue(daten).kontakt assert.equal(k.telefon, '491711234567') assert.equal(k.routeUrl, 'https://www.google.com/maps/dir/?api=1&destination=' + encodeURIComponent(ADRESSE)) assert.equal(k.whatsappUrl, 'https://wa.me/491711234567?text=' + encodeURIComponent(STARTTEXT)) assert.ok(!k.whatsappUrl.includes(' ') && !k.whatsappUrl.includes('&möchte'), 'Starttext ist kodiert') assert.deepEqual(Object.keys(k), ['telefon', 'routeUrl', 'whatsappUrl']) }) test('WhatsApp nur mit bestaetigtAt; ohne Starttext bleibt der Text leer', () => { const kontakt = (whatsapp, extra = {}) => ({ plaene: [], kontakt: { an: true, telefon: '', adresse: '', whatsapp, ...extra } }) assert.equal(baue(kontakt({ nummer: '491711234567', starttext: 'Hallo', bestaetigtAt: null })).kontakt, null, 'unbestaetigt und sonst nichts: keine Leiste') assert.equal(baue(kontakt({ nummer: '491711234567', starttext: 'Hallo', bestaetigtAt: '' })).kontakt, null) const mit = baue(kontakt({ nummer: '491711234567', starttext: 'Hallo', bestaetigtAt: null }, { telefon: '491701111111' })).kontakt assert.deepEqual(mit, { telefon: '491701111111', routeUrl: null, whatsappUrl: null }) const ohneText = baue(kontakt({ nummer: '491711234567', starttext: '', bestaetigtAt: '2026-10-01T08:00:00.000Z' })).kontakt assert.equal(ohneText.whatsappUrl, 'https://wa.me/491711234567?text=') }) test('Telefonnummern gehen nur als Ziffern hinaus (kein tel:-Einschleusen aus beschaedigten Daten)', () => { // mit gueltiger Adresse, damit die Leiste bestehen bleibt und die Telefonfelder einzeln zu sehen sind const k = (telefon, nummer = '491711234567') => baue({ plaene: [], kontakt: { an: true, telefon, adresse: ADRESSE, whatsapp: { nummer, starttext: '', bestaetigtAt: '2026-10-01T08:00:00.000Z' } } }).kontakt assert.equal(k('491711234567; rm', '49171 123').telefon, null) assert.equal(k('491711234567; rm', '49171 123').whatsappUrl, null) assert.equal(k('+491711234567').telefon, null, 'gespeichert werden nur Ziffern') assert.equal(k(491711234567).telefon, null, 'Zahl statt Text') assert.equal(k('12345').telefon, null, 'zu kurz fuer eine Nummer mit Vorwahl') assert.equal(k('0171123456').telefon, null, 'fuehrende 0: gespeichert wird die Vorwahl 49') assert.equal(k('4917112345678901234').telefon, null, 'laenger als 15 Ziffern') assert.equal(k('491711234567').telefon, '491711234567') assert.equal(k('491711234567\n').telefon, null, 'auch kein Zeilenumbruch hinten') }) test('ein einzelnes Surrogat in Starttext oder Adresse bringt die Antwort nicht zu Fall', () => { const k = baue({ plaene: [], kontakt: { an: true, telefon: '', adresse: 'Hauptstr. \ud800 1', whatsapp: { nummer: '491711234567', starttext: 'Hallo \udc00!', bestaetigtAt: '2026-10-01T08:00:00.000Z' } }, }).kontakt assert.equal(k.whatsappUrl, 'https://wa.me/491711234567?text=Hallo%20%EF%BF%BD!') assert.equal(k.routeUrl, 'https://www.google.com/maps/dir/?api=1&destination=Hauptstr.%20%EF%BF%BD%201') }) test('firma: Name vom Aufrufer, Adresse = kontakt.adresse (getrimmt; leer, fehlend oder kein Text = null)', () => { const firma = (kontakt, name = 'X') => baue({ plaene: [], kontakt }, { firma: { name } }).firma assert.deepEqual(firma({ an: true, telefon: '', adresse: ` ${ADRESSE} `, whatsapp: null }), { name: 'X', adresse: ADRESSE }) assert.deepEqual(firma({ an: false, telefon: '', adresse: ADRESSE, whatsapp: null }), { name: 'X', adresse: ADRESSE }, 'unabhaengig von der Kontakt-Leiste (Spec 4.1)') for (const kontakt of [{ adresse: ' ' }, { adresse: 5 }, {}, null, 'x']) assert.equal(firma(kontakt).adresse, null, JSON.stringify(kontakt)) assert.deepEqual(baue(null, { firma: undefined }).firma, { name: '', adresse: null }) assert.deepEqual(baue(null, { firma: { name: 5 } }).firma, { name: '', adresse: null }, 'kein Text = leer') assert.deepEqual(baue(null, { firma: { name: ' Büro ' } }).firma, { name: 'Büro', adresse: null }) }) test('team: Vorgabe bei fehlenden oder kaputten Angaben, keine fremden Schluessel', () => { const vorgabe = { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false } for (const team of [undefined, null, {}, 'x', [], { navSelektor: '', kontaktleisteAus: 'ja', hinweisleisteAus: 1 }]) { assert.deepEqual(baue(null, { team }).team, vorgabe, JSON.stringify(team)) } assert.deepEqual(baue(null, { team: { navSelektor: 'header', kontaktleisteAus: true, hinweisleisteAus: false, intern: 'x' } }).team, { navSelektor: 'header', kontaktleisteAus: true, hinweisleisteAus: false }) }) test('kaputte gespeicherte Daten werfen nicht', () => { for (const daten of [ { plaene: 'x', hinweise: {}, kontakt: 'x' }, { plaene: [null, 5, { kurz: 'a' }, { kurz: 'b', haupt: true, tage: 'x' }, { name: 'ohne kurz' }], hinweise: [null, { text: 'x' }], kontakt: [] }, { plaene: [{ kurz: 'a', name: 'A', haupt: true, tage: { 1: { modus: 'hm', fenster: 'x' } } }], ausnahmen: 7, feiertage: [], land: 'XX' }, ]) { const a = baue(daten) assert.equal(a.aktiv, true) assert.ok(Array.isArray(a.plaene) && Array.isArray(a.hinweise)) JSON.stringify(a) } const a = baue({ plaene: [{ kurz: 'a', name: 'A', haupt: true, tage: { 1: { modus: 'hm' } } }, { name: 'ohne kurz' }, null] }) assert.deepEqual(a.plaene.map((p) => p.kurz), ['a'], 'Plaene ohne Kurznamen koennen nicht angesprochen werden und fallen weg') assert.deepEqual(a.plaene[0].woche[0], { tag: 1, modus: 'zu', fenster: [] }, 'unbekannter Modus = zu') }) test('ungueltiges now ist ein Programmierfehler', () => { for (const now of [undefined, null, 'heute', new Date('x'), 1]) assert.throws(() => baue(null, { now }), TypeError) }) // ---------------------------------------------------------------- oeffentlicheZeitenAntwort test('Schalter aus: aktiv false, ohne Abfrage und ohne Cache-Eintrag', async () => { legeAn() config.zeiten.mode = 'off' assert.deepEqual(await oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { aktiv: false }) assert.equal(listen(), 0) assert.equal(zeitenCache.groesse(), 0) config.zeiten.mode = 'pilot' assert.equal((await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)).aktiv, true, 'pilot und on beantworten') }) test('ungültiger Schlüssel: keine Abfrage, kein Cache-Eintrag', async () => { legeAn() for (const key of ['', 'ABCDABCDABCDABCD', 'abcdabcdabcdabc', 'abcdabcdabcdabcde', 'abcdabcdabcdabc1', 'abcdabcdabcdabc0', 'abcdabcdabcdabc!', 'abcd abcdabcdabc', undefined, null, 42, {}, [SITE_KEY], { toString: () => SITE_KEY }]) { assert.deepEqual(await oeffentlicheZeitenAntwort(key, JETZT), { aktiv: false }, String(key)) } assert.equal(aw.listCalls().length, 0, 'keine einzige Listenabfrage an Appwrite') assert.equal(zeitenCache.groesse(), 0) }) test('unbekannter, gueltig geformter Schluessel: aktiv false, wird gemerkt', async () => { legeAn() assert.deepEqual(await oeffentlicheZeitenAntwort('zzzzzzzzzzzzzzzz', JETZT), { aktiv: false }) assert.equal(listen(), 1) assert.equal(zeitenCache.groesse(), 1) assert.deepEqual(await oeffentlicheZeitenAntwort('zzzzzzzzzzzzzzzz', new Date(JETZT.getTime() + 29000)), { aktiv: false }) assert.equal(listen(), 1, 'auch das Unbekannte kommt aus dem Speicher') }) test('Zustand: setup und active liefern dasselbe, off, ordered und disabled nur aktiv false', async () => { legeAn() await gespeicherteDaten() const active = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT) assert.equal(active.aktiv, true) for (const state of ['setup']) { legeAn({ feature: { state, enabled: false } }) resetZeitenCacheForTests() assert.deepEqual(await oeffentlicheZeitenAntwort(SITE_KEY, JETZT), active, state) } for (const state of ['off', 'ordered', 'disabled']) { legeAn({ feature: { state, enabled: false } }) resetZeitenCacheForTests() assert.deepEqual(await oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { aktiv: false }, state) } }) test('nur die Funktion zeiten: der Schluessel von News oder Termine gibt aktiv false', async () => { legeAn() for (const featureKey of ['news', 'termine']) { aw.seed('portalFeatures', { ...FEATURE, $id: `f_p_projekt01_${featureKey}`, featureKey, siteKey: 'nnnnnnnnnnnnnnnn' }) resetZeitenCacheForTests() assert.deepEqual(await oeffentlicheZeitenAntwort('nnnnnnnnnnnnnnnn', JETZT), { aktiv: false }, featureKey) await deleteDocument('portalFeatures', `f_p_projekt01_${featureKey}`) } }) test('Kunde gelöscht ⇒ aktiv false', async () => { legeAn({ kunde: null }) await gespeicherteDaten() assert.deepEqual(await oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { aktiv: false }) assert.equal(zeitenCache.groesse(), 1, 'auch das wird gemerkt') }) test('Funktion ohne Kunden-ID ⇒ aktiv false', async () => { legeAn({ feature: { customerId: '' } }) assert.deepEqual(await oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { aktiv: false }) }) test('vor dem ersten Speichern (ohne Dokument): aktiv true, plaene leer, kein jsonld, Standard-Team', async () => { legeAn() const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT) assert.equal(a.aktiv, true) assert.deepEqual(a.plaene, []) assert.equal(a.jsonld, null) assert.equal(a.kontakt, null) assert.deepEqual(a.team, { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false }) assert.deepEqual(a.firma, { name: 'Bäckerei Sonnenschein', adresse: null }) }) test('nur Team-Einstellungen gespeichert (Dokument ohne daten): Team gilt, plaene leer', async () => { legeAn() await speichereTeam({ feature: FEATURE, eingabe: { navSelektor: '#kopf', kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 0, now: JETZT }) const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT) assert.deepEqual(a.plaene, []) assert.deepEqual(a.team, { navSelektor: '#kopf', kontaktleisteAus: true, hinweisleisteAus: false }) }) test('unlesbare daten gelten wie vor dem ersten Speichern', async () => { legeAn() aw.seed('oeffnungszeiten', { $id: FEATURE_ID, customerId: KUNDE, projectId: PROJEKT, featureId: FEATURE_ID, daten: '{kaputt', version: 3, teamVersion: 0 }) const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT) assert.equal(a.aktiv, true) assert.deepEqual(a.plaene, []) }) test('volle Antwort aus gespeicherten Daten: Pläne, Hinweise, Kontakt, firma.adresse und Team', async () => { legeAn() await gespeicherteDaten() await speichereTeam({ feature: FEATURE, eingabe: TEAM, teamVersion: 0, now: JETZT }) const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT) assert.deepEqual(Object.keys(a), ['aktiv', 'stand', 'plaene', 'hinweise', 'kontakt', 'jsonld', 'firma', 'team']) assert.equal(a.stand, JETZT.toISOString()) assert.deepEqual(a.plaene.map((p) => p.kurz), ['standard', 'kueche']) assert.equal(a.hinweise.length, 2) assert.deepEqual(a.kontakt, { telefon: '491711234567', routeUrl: 'https://www.google.com/maps/dir/?api=1&destination=' + encodeURIComponent(ADRESSE), whatsappUrl: 'https://wa.me/491711234567?text=' + encodeURIComponent(STARTTEXT), }) assert.deepEqual(a.firma, { name: 'Bäckerei Sonnenschein', adresse: ADRESSE }) assert.deepEqual(a.team, TEAM) assert.equal(a.jsonld.openingHoursSpecification.length, 6) }) test('firma ohne Firmenname fällt auf den Kundennamen, dann auf den Projektnamen, sonst leer', async () => { const name = async (kunde, projekt) => { for (const c of ['portalFeatures', 'customers', 'websiteProjects']) for (const id of [...aw.docs(c).keys()]) await deleteDocument(c, id) legeAn({ kunde, projekt }) resetZeitenCacheForTests() const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT) assert.equal(a.aktiv, true) return a.firma.name } assert.equal(await name({ name: 'Frau Sonne', companyName: 'Bäckerei Sonnenschein' }, { projectName: 'P' }), 'Bäckerei Sonnenschein') assert.equal(await name({ name: 'Frau Sonne', companyName: '' }, { projectName: 'P' }), 'Frau Sonne') assert.equal(await name({ name: 'Frau Sonne' }, { projectName: 'P' }), 'Frau Sonne') assert.equal(await name({ name: ' ', companyName: ' ' }, { projectName: 'Sonnenschein-Website' }), 'Sonnenschein-Website') assert.equal(await name({ email: 'x@y.de' }, { projectName: 'Sonnenschein-Website' }), 'Sonnenschein-Website') assert.equal(await name({ email: 'x@y.de' }, null), '', 'weder Kundenname noch Projekt: leer, nie ein Fehler') assert.equal(await name({ email: 'x@y.de' }, { projectName: '' }), '') }) test('zweiter Abruf innerhalb von 30 s aus dem Cache', async () => { legeAn() await gespeicherteDaten() const erste = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT) assert.equal(listen(), 1) // Aenderungen in Appwrite bleiben bis zum Ablauf unsichtbar aw.seed('portalFeatures', { ...FEATURE, state: 'disabled', enabled: false }) const zweite = await oeffentlicheZeitenAntwort(SITE_KEY, new Date(JETZT.getTime() + 29999)) assert.equal(listen(), 1, 'keine neue Abfrage') assert.deepEqual(zweite, erste) assert.equal(zweite.stand, JETZT.toISOString(), 'stand ist der Zeitpunkt des Bauens') const dritte = await oeffentlicheZeitenAntwort(SITE_KEY, new Date(JETZT.getTime() + 30000)) assert.equal(listen(), 2, 'nach 30 s wird neu gebaut') assert.deepEqual(dritte, { aktiv: false }) }) test('der Cache gilt je Schluessel', async () => { legeAn() aw.seed('portalFeatures', { ...FEATURE, $id: 'f_p_projekt02_zeiten', projectId: 'p_projekt02', siteKey: 'wxyzwxyzwxyzwxyz' }) assert.equal((await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)).aktiv, true) assert.equal((await oeffentlicheZeitenAntwort('wxyzwxyzwxyzwxyz', JETZT)).aktiv, true) assert.equal(listen(), 2) assert.equal(zeitenCache.groesse(), 2) }) test('Speichern verwirft die Antwort: die naechste Anfrage sieht die neuen Zeiten sofort', async () => { legeAn() const daten = await gespeicherteDaten() const vorher = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT) assert.equal(vorher.plaene[0].name, 'Standard') const neu = { ...daten, plaene: daten.plaene.map((p) => (p.haupt ? { ...p, name: 'Hauptzeiten' } : p)) } await speichereZeiten({ feature: FEATURE, eingabe: neu, version: 1, by: 'kunde', now: JETZT }) const nachher = await oeffentlicheZeitenAntwort(SITE_KEY, new Date(JETZT.getTime() + 1000)) assert.equal(nachher.plaene[0].name, 'Hauptzeiten') }) test('keine internen IDs in der Antwort', async () => { legeAn() await gespeicherteDaten() await speichereTeam({ feature: FEATURE, eingabe: TEAM, teamVersion: 0, now: JETZT }) const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT) const { schluessel, werte } = durchsuche(a) for (const verboten of ['$id', '$collectionId', '$databaseId', 'customerId', 'projectId', 'featureId', 'siteKey', 'id', 'version', 'teamVersion', 'updatedBy', 'ersatzHash', 'email']) { assert.ok(!schluessel.has(verboten), `Schluessel ${verboten}`) } const text = JSON.stringify(a) for (const intern of [FEATURE_ID, KUNDE, PROJEKT, SITE_KEY, P_STANDARD, P_KUECHE, 'x_urlaub01', 'h_aktion01', 'h_dauer001', 'Frau Sonne']) { assert.ok(!text.includes(intern), `Wert ${intern}`) } assert.ok(werte.length > 20) }) test('Appwrite-Fehler gehen an den Aufrufer, ohne dass etwas gemerkt wird', async () => { legeAn() await gespeicherteDaten() aw.failNext({ collection: 'portalFeatures', status: 500, message: 'kaputt' }) await assert.rejects(() => oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { status: 500 }) assert.equal(zeitenCache.groesse(), 0) aw.failNext({ collection: 'customers', status: 500, message: 'kaputt' }) await assert.rejects(() => oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { status: 500 }) aw.failNext({ collection: 'oeffnungszeiten', status: 500, message: 'kaputt' }) await assert.rejects(() => oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { status: 500 }) assert.equal(zeitenCache.groesse(), 0) assert.equal((await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)).aktiv, true, 'danach geht es wieder') }) test('ohne now rechnet die Funktion mit der aktuellen Zeit', async () => { legeAn() await gespeicherteDaten() const vorher = Date.now() const a = await oeffentlicheZeitenAntwort(SITE_KEY) assert.ok(Date.parse(a.stand) >= vorher && Date.parse(a.stand) <= Date.now()) }) test('die gemerkte Antwort ist gegen Veraenderung geschuetzt', async () => { legeAn() await gespeicherteDaten() const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT) assert.throws(() => { a.plaene[0].name = 'gehackt' }, TypeError) assert.throws(() => { a.firma.name = 'gehackt' }, TypeError) assert.equal((await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)).plaene[0].name, 'Standard') })