diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index bab1078..9b203cd 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -31,6 +31,7 @@ import { anfrageStatusJeProjekt, katalogFuerKunde } from '../services/aboKatalog import { datumDe, einschalten, EINSCHALTEN_TEXTE, pruefeAboDeckung, pruefeEinschaltZustand } from '../services/funktionEinschalten.js' import { kundenartWirksam, NUR_BETRIEB_TEXT } from '../services/kundenart.js' import { neueAnsicht } from '../services/kundenansicht/schalter.js' +import { ergaenzeKurzinfos } from '../services/kundenansicht/kurzinfo.js' import { abowegAnalyse, aboweg, consentAbo, consentAboAktiv, featureConfig, featureHaken, featureKeys, featureSperre, freiweg, katalogFelder, oeffentlicheAnsicht } from '../services/featureRegistry.js' import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js' @@ -253,7 +254,8 @@ router.get('/', requireSession, async (req, res) => { // dem Abo-Stand (inklusive, grund), dem Preis und den Texten des Gratisweges "Einschalten" zeiten: await zeitenAngebot(req.session.email, customerId, abos), } - // Kundenansicht neu (V5): Abo-Modell mit Kundenart, Abo-Stand, Katalog und Anfragestatus von Webshop und Buchungskalender je Projekt. + // Kundenansicht neu (V5): Abo-Modell mit Kundenart, Abo-Stand, Katalog und Anfragestatus von Webshop und Buchungskalender je Projekt, + // dazu je Projekt und Funktion die Kurzinfo (Live-Detail der Zeile in "Meine", Text oder null; jede Abfrage begrenzt und fehlertolerant). // Ohne neue Ansicht bleibt die Antwort wie sie war. Ein Lesefehler beim Abo-Stand ist ein Fehler der Liste (nie ein erfundenes "kein Abo"). if (neu) { const [kunde, abo, katalog, anfrageStatus] = await Promise.all([ @@ -261,6 +263,7 @@ router.get('/', requireSession, async (req, res) => { aboProdukt(customerId, { now: jetzt }), katalogFuerKunde({ email: req.session.email }), anfrageStatusJeProjekt({ docs, projects }), + ergaenzeKurzinfos({ projects, docs, jetzt }).catch((err) => console.error(`[funktionen] Kurzinfo nicht lesbar: ${err?.name ?? 'Fehler'}`)), ]) for (const p of projects) { p.features.shop.anfrageStatus = anfrageStatus.get(p.projectId)?.shop ?? '' diff --git a/server/services/kundenansicht/kurzinfo.js b/server/services/kundenansicht/kurzinfo.js new file mode 100644 index 0000000..d396e64 --- /dev/null +++ b/server/services/kundenansicht/kurzinfo.js @@ -0,0 +1,158 @@ +import { config } from '../../config.js' +import { Query, countDocuments, listDocuments } from '../appwriteAdmin.js' +import { isHanging } from '../newsPosts.js' +import { heute, plusTage, uhrzeit, zuUtc } from '../termine/zeit.js' + +/** + * Kurzinfo der aktiven Funktionen fuer GET /api/funktionen in der neuen Ansicht: je Projekt und Funktion `kurzinfo`, ein kurzer + * Text fuer die zweite Zeile in "Meine" ("3 Termine heute · nächster 10:30", "2 neue Bestellungen", ...) oder null. Berliner Zeit. + * + * Regeln: + * - Nur Funktionen im Zustand active bekommen einen Text (und eine Abfrage); alle anderen null. Die Analyse hat keine Kurzinfo + * (ihre Zahlen kommen aus dem Auswertungsdienst, nicht aus Appwrite: eine Netzabfrage je Seitenaufruf lohnt dafuer nicht). + * - Je Funktion hoechstens zwei schmale Abfragen (limit, select, Filter auf Index-Felder), alle Funktionen und Websites gleichzeitig. + * - Jede Funktion ist einzeln begrenzt (kurzinfoDeps.limitMs) und fehlertolerant: Fehler oder Zeitueberschreitung ergibt null, die + * Oberflaeche zeigt dann "Aktiv". Im Log stehen nur Funktion, Art und Status, nie Text oder Daten (Gaeste, Kaeufer). + * - Ist eine Seite voll (limit erreicht), ist die Zahl unbekannt: null statt einer falschen Zahl. + * - Die Texte nennen weder Namen noch Adressen noch Betraege. + */ + +/** Einhaengepunkt fuer Tests: Zeitgrenze je Funktion in Millisekunden. */ +export const kurzinfoDeps = { limitMs: 1500 } + +const SEITE = 100 +const SEITE_NEWS = 200 +const MAX_ZEICHEN = 80 + +const mehrzahl = (n, eins, viele) => `${n} ${n === 1 ? eins : viele}` +const istText = (wert) => typeof wert === 'string' && wert.trim() !== '' +const zeitMs = (wert) => Date.parse(wert) + +/** Texte der Funktionen (rein): Eingabe sind die gezaehlten Werte, Ausgabe der Satz. */ +export const KURZINFO_TEXTE = { + termine: ({ anzahl, naechster }) => + anzahl === 0 ? 'Heute keine Termine' : `${mehrzahl(anzahl, 'Termin', 'Termine')} heute${naechster ? ` · nächster ${naechster}` : ''}`, + shop: ({ anzahl }) => (anzahl === 0 ? 'Keine offenen Bestellungen' : `${mehrzahl(anzahl, 'neue Bestellung', 'neue Bestellungen')}`), + news: ({ anzahl }) => (anzahl === 0 ? 'Keine Beiträge sichtbar' : `${mehrzahl(anzahl, 'Beitrag', 'Beiträge')} sichtbar`), + unterkunft: ({ offen, anreise }) => { + if (offen > 0) return mehrzahl(offen, 'offene Anfrage', 'offene Anfragen') + return anreise ? anreise : 'Keine offenen Anfragen' + }, + zeitenLeer: () => 'Noch keine Öffnungszeiten eingetragen', +} + +/** 'JJJJ-MM-TT' → 'TT.MM.' */ +const tagKurz = (tag) => `${tag.slice(8, 10)}.${tag.slice(5, 7)}.` + +/** Terminplaner: bestaetigte und erledigte Termine von heute (Berliner Tag) ohne Sperren; der naechste beginnt nach jetzt. */ +async function termine({ dok, jetzt }) { + if (!dok) return null + const tag = heute(jetzt) + const von = zuUtc(tag, '00:00') + const bis = zuUtc(plusTage(tag, 1), '00:00') + if (!von || !bis) return null + const liste = await listDocuments(config.collections.termineBookings, [ + Query.equal('featureId', dok.$id), + Query.equal('status', ['bestaetigt', 'erledigt']), + Query.greaterThanEqual('startsAt', von.toISOString()), + Query.lessThan('startsAt', bis.toISOString()), + Query.limit(SEITE), + Query.select(['startsAt', 'quelle']), + ]) + if (liste.length >= SEITE) return null + const starts = liste.filter((b) => b.quelle !== 'sperre').map((b) => zeitMs(b.startsAt)).filter(Number.isFinite) + const spaeter = starts.filter((ms) => ms > jetzt.getTime()) + const naechster = spaeter.length ? uhrzeit(new Date(Math.min(...spaeter))) : '' + return KURZINFO_TEXTE.termine({ anzahl: starts.length, naechster }) +} + +/** Webshop: bezahlte Bestellungen, die noch niemand bearbeitet hat (nur die Anzahl, total der Antwort). */ +async function shop({ dok }) { + if (!dok) return null + const anzahl = await countDocuments(config.collections.shopOrders, [Query.equal('shopId', dok.$id), Query.equal('status', 'bezahlt')]) + return Number.isInteger(anzahl) ? KURZINFO_TEXTE.shop({ anzahl }) : null +} + +/** News-Pinnwand: haengende Beitraege dieser Website (Zeiten und Abnahme wie die Website sie auswertet, isHanging). */ +async function news({ projekt, jetzt }) { + const liste = await listDocuments(config.collections.newsPosts, [ + Query.equal('projectId', projekt.projectId), + Query.limit(SEITE_NEWS), + Query.select(['visibleFrom', 'visibleUntil', 'takenDownAt']), + ]) + if (liste.length >= SEITE_NEWS) return null + return KURZINFO_TEXTE.news({ anzahl: liste.filter((p) => isHanging(p, jetzt)).length }) +} + +/** Buchungskalender: offene (neue) Anfragen; sonst die naechste Anreise einer gebuchten oder bezahlten Anfrage. */ +async function unterkunft({ dok, jetzt }) { + if (!dok) return null + const tag = heute(jetzt) + const [offen, kommende] = await Promise.all([ + countDocuments(config.collections.ukAnfragen, [Query.equal('featureId', dok.$id), Query.equal('status', 'neu')]), + listDocuments(config.collections.ukAnfragen, [ + Query.equal('featureId', dok.$id), + Query.equal('status', ['gebucht', 'bezahlt']), + Query.greaterThanEqual('bis', tag), + Query.limit(SEITE), + Query.select(['von', 'bis']), + ]), + ]) + if (!Number.isInteger(offen) || kommende.length >= SEITE) return null + const anreisen = kommende.map((a) => a.von).filter((v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) && v >= tag).sort() + let anreise = '' + if (anreisen.length) { + const erste = anreisen[0] + anreise = erste === tag ? 'Anreise heute' : erste === plusTage(tag, 1) ? 'Anreise morgen' : `Nächste Anreise ${tagKurz(erste)}` + } + return KURZINFO_TEXTE.unterkunft({ offen, anreise }) +} + +/** Oeffnungszeiten live: der Status, den die Projektliste ohnehin berechnet hat (zeitenStatus), ohne eigene Abfrage. */ +function zeiten({ projekt }) { + const text = projekt.zeitenStatus?.text + if (istText(text)) return text + return projekt.zeitenLeer === true ? KURZINFO_TEXTE.zeitenLeer() : null +} + +const LESER = { termine, shop, news, unterkunft, zeiten } + +/** Nur ein nicht leerer, kurzer Text zaehlt, alles andere ist null. */ +const gueltig = (wert) => (istText(wert) && wert.length <= MAX_ZEICHEN ? wert : null) + +function logge(key, err) { + console.error(`[funktionen] Kurzinfo (${key}) nicht lesbar: ${err ? `${err?.name ?? 'Fehler'} ${err?.status ?? ''}` : 'Zeitüberschreitung'}`.trim()) +} + +/** Fuehrt einen Leser mit Zeitgrenze aus: Text oder null, wirft nie. Eine zu spaete Antwort wird verworfen. */ +function begrenzt(key, leser) { + return new Promise((resolve) => { + const uhr = setTimeout(() => { logge(key, null); resolve(null) }, kurzinfoDeps.limitMs) + Promise.resolve() + .then(leser) + .then( + (wert) => { clearTimeout(uhr); resolve(gueltig(wert)) }, + (err) => { clearTimeout(uhr); logge(key, err); resolve(null) }, + ) + }) +} + +/** + * Setzt `kurzinfo` an jede Funktion jedes Projekts (projects[].features[key], die Form von listCustomerFeatures): einen Text fuer aktive + * Funktionen, sonst null. Wirft nie wegen einer einzelnen Funktion; alle Abfragen laufen gleichzeitig. + * @param {{ projects: Array<{ projectId: string, features: object, zeitenStatus?: object|null, zeitenLeer?: boolean }>, docs: object[], jetzt: Date }} p + * docs: portalFeatures-Dokumente des Kunden; jetzt: der eine Zeitpunkt der Antwort + */ +export async function ergaenzeKurzinfos({ projects, docs, jetzt }) { + const aufgaben = [] + for (const projekt of projects) { + for (const [key, funktion] of Object.entries(projekt.features ?? {})) { + funktion.kurzinfo = null + const leser = LESER[key] + if (!leser || funktion.state !== 'active') continue + const dok = docs.find((d) => d.projectId === projekt.projectId && d.featureKey === key) + aufgaben.push(begrenzt(key, () => leser({ projekt, dok, jetzt })).then((text) => { funktion.kurzinfo = text })) + } + } + await Promise.all(aufgaben) +} diff --git a/test/kundenansichtKurzinfo.test.js b/test/kundenansichtKurzinfo.test.js new file mode 100644 index 0000000..fd441a6 --- /dev/null +++ b/test/kundenansichtKurzinfo.test.js @@ -0,0 +1,359 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' +import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' +import { UNTERKUNFT_DATETIMES } from '../server/services/unterkunft/schema.js' + +// GET /api/funktionen in der neuen Ansicht: je Projekt und Funktion `kurzinfo` (Live-Detail der Zeile in "Meine"). Nur aktive Funktionen +// bekommen einen Text, jede Einzelabfrage ist begrenzt und fehlertolerant (Fehler oder Zeitueberschreitung ergibt null), Berliner Zeit. +// Ohne neue Ansicht bleibt die Antwort wie sie war und nichts davon wird gelesen. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + featureConsents: schemaKeys('featureConsents'), + shopConfig: schemaKeys('shopConfig'), + ukEinstellungen: schemaKeys('ukEinstellungen'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + termineBookings: schemaKeys('termineBookings'), + shopOrders: schemaKeys('shopOrders'), + newsPosts: schemaKeys('newsPosts'), + ukAnfragen: schemaKeys('ukAnfragen'), + }, + datetimes: { + ...ZEITEN_DATETIMES, + termineBookings: TERMINE_DATETIMES.termineBookings, + shopOrders: SHOP_DATETIMES.shopOrders, + ukAnfragen: UNTERKUNFT_DATETIMES.ukAnfragen, + newsPosts: ['visibleFrom', 'visibleUntil', 'takenDownAt', 'createdAt', 'updatedAt'], + portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'], + featureConsents: ['acceptedAt'], + }, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, get } = await import('./support/testApp.js') +const { customerCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') +const { einschaltenDeps } = await import('../server/services/zeiten/einschalten.js') +const { kurzinfoDeps } = await import('../server/services/kundenansicht/kurzinfo.js') + +const app = await startTestApp({ '/api/funktionen': funktionenRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) +after(() => setMailTransportForTests(null)) + +const JETZT = new Date('2026-10-17T08:00:00.000Z') // Samstag 10:00 Uhr in Berlin (Sommerzeit) +const c1 = customerCookie('c1') +const vorher = { + neu: { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] }, + termine: config.termine.mode, + shop: { mode: config.shop.mode, pilotEmails: [...config.shop.pilotEmails] }, + unterkunft: { mode: config.unterkunft.mode, pilotEmails: [...config.unterkunft.pilotEmails] }, + zeiten: { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] }, + analyse: config.analyse.enabled, +} +after(() => { + Object.assign(config.kundenansichtNeu, vorher.neu) + config.termine.mode = vorher.termine + Object.assign(config.shop, vorher.shop) + Object.assign(config.unterkunft, vorher.unterkunft) + Object.assign(config.zeiten, vorher.zeiten) + config.analyse.enabled = vorher.analyse +}) + +const SAMMLUNGEN = ['subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects', 'contractEvents', 'shopConfig', 'ukEinstellungen', 'termineBookings', 'shopOrders', 'newsPosts', 'ukAnfragen'] +const leere = async (...collections) => { + for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id) +} +const projekt = (id, over = {}) => aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over }) +const fid = (p, key) => `f_${p}_${key}` +const funktion = (p, key, state = 'active', over = {}) => + aw.seed('portalFeatures', { $id: fid(p, key), customerId: 'c1', projectId: p, featureKey: key, siteKey: `${key}${p}`.padEnd(16, 'x').slice(0, 16), state, enabled: state === 'active', ...over }) +const NAMEN = ['news', 'termine', 'shop', 'unterkunft', 'zeiten'] +const alleAktiv = (p) => NAMEN.forEach((k) => funktion(p, k)) +const inStd = (h) => new Date(JETZT.getTime() + h * 3600000).toISOString() + +let zaehler = 0 +const nr = () => `d${(zaehler += 1)}` +const buchung = (p, over) => aw.seed('termineBookings', { $id: nr(), featureId: fid(p, 'termine'), customerId: 'c1', projectId: p, status: 'bestaetigt', quelle: 'online', endsAt: inStd(1), blockEndsAt: inStd(1), ...over }) +const bestellung = (p, status, over = {}) => aw.seed('shopOrders', { $id: nr(), shopId: fid(p, 'shop'), customerId: 'c1', status, bestellnummer: nr(), summeCent: 1000, erstelltAt: inStd(-5), ...over }) +const zettel = (p, over = {}) => aw.seed('newsPosts', { $id: nr(), customerId: 'c1', projectId: p, type: 'note', title: 'Hallo', text: 'Text', paper: 'sun', duration: 'open', visibleFrom: inStd(-48), visibleUntil: null, takenDownAt: null, createdAt: inStd(-48), updatedAt: inStd(-48), ...over }) +const anfrage = (p, status, von, bis, over = {}) => aw.seed('ukAnfragen', { $id: nr(), featureId: fid(p, 'unterkunft'), customerId: 'c1', projectId: p, unterkunftId: 'u1', nummer: nr(), von, bis, status, personen: 2, ...over }) +function plan(woche) { + const tage = {} + for (let d = 1; d <= 7; d += 1) tage[d] = woche[d] ? { modus: 'zeiten', fenster: [woche[d]] } : { modus: 'zu', fenster: [] } + return { id: 'p1', kurz: 'standard', name: 'Standard', haupt: true, tage } +} +const zeitenDaten = (woche) => ({ land: 'RP', plaene: [plan(woche)], feiertage: {}, ausnahmen: [], hinweise: [], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null } }) +const zeitenDok = (p, daten) => aw.seed('oeffnungszeiten', { $id: fid(p, 'zeiten'), customerId: 'c1', projectId: p, featureId: fid(p, 'zeiten'), version: 1, teamVersion: 0, daten: JSON.stringify(daten) }) + +const holen = async (cookie = c1) => (await get('/api/funktionen', { cookie })) +const info = (b, p, key) => b.projects.find((x) => x.projectId === p).features[key].kurzinfo +const abfragen = (sammlung) => aw.listCalls(sammlung) + +beforeEach(async () => { + await leere(...SAMMLUNGEN) + resetZeitenCacheForTests() + kurzinfoDeps.limitMs = 1500 + zaehler = 0 + seedFixtures(aw) + aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc })) + projekt('p1') + config.kundenansichtNeu.mode = 'pilot' + config.kundenansichtNeu.pilotEmails = ['k@example.invalid'] + config.termine.mode = 'on' + config.shop.mode = 'on' + config.unterkunft.mode = 'on' + config.zeiten.mode = 'pilot' + config.zeiten.pilotEmails = ['*'] + config.analyse.enabled = false + einschaltenDeps.jetzt = () => JETZT + setMailTransportForTests(mailSink()) + mock.method(console, 'error', () => {}) + mock.method(console, 'warn', () => {}) + mock.method(console, 'log', () => {}) + aw.resetListCalls() +}) +afterEach(() => { + mock.restoreAll() + einschaltenDeps.jetzt = () => new Date() +}) + +/* ------------------------------------------------------------------------------------------ Terminplaner --- */ +test('Terminplaner: Termine von heute (Berliner Tag), nächster nach jetzt; Sperren, Abgesagte, andere Tage und andere Funktionen zählen nicht', async () => { + funktion('p1', 'termine'); projekt('p2'); funktion('p2', 'termine') + buchung('p1', { status: 'erledigt', startsAt: '2026-10-17T06:00:00.000Z' }) // 08:00 Berlin, schon vorbei, zählt + buchung('p1', { startsAt: '2026-10-17T08:30:00.000Z' }) // 10:30, der nächste + buchung('p1', { startsAt: '2026-10-17T12:00:00.000Z' }) // 14:00 + buchung('p1', { status: 'abgesagt', startsAt: '2026-10-17T09:00:00.000Z' }) + buchung('p1', { quelle: 'sperre', startsAt: '2026-10-17T09:30:00.000Z' }) + buchung('p1', { status: 'angefragt', startsAt: '2026-10-17T10:00:00.000Z' }) + buchung('p1', { startsAt: '2026-10-18T08:00:00.000Z' }) // morgen + buchung('p1', { startsAt: '2026-10-16T21:30:00.000Z' }) // 23:30 Berlin, gestern + buchung('p2', { startsAt: '2026-10-17T07:00:00.000Z' }) // andere Website + const b = await holen() + assert.equal(info(b, 'p1', 'termine'), '3 Termine heute · nächster 10:30') + assert.equal(info(b, 'p2', 'termine'), '1 Termin heute') +}) + +test('Terminplaner: ein Termin, alle vorbei, keiner', async () => { + funktion('p1', 'termine') + assert.equal(info(await holen(), 'p1', 'termine'), 'Heute keine Termine') + buchung('p1', { startsAt: '2026-10-17T11:00:00.000Z' }) + assert.equal(info(await holen(), 'p1', 'termine'), '1 Termin heute · nächster 13:00') + await leere('termineBookings') + buchung('p1', { status: 'erledigt', startsAt: '2026-10-17T05:00:00.000Z' }) + buchung('p1', { status: 'erledigt', startsAt: '2026-10-17T06:00:00.000Z' }) + assert.equal(info(await holen(), 'p1', 'termine'), '2 Termine heute') +}) + +test('Terminplaner: der Tag ist der Berliner Tag, auch kurz nach Mitternacht (UTC liegt noch beim Vortag)', async () => { + funktion('p1', 'termine') + einschaltenDeps.jetzt = () => new Date('2026-10-17T22:30:00.000Z') // 00:30 Berlin am 18.10. + buchung('p1', { startsAt: '2026-10-17T21:59:00.000Z' }) // 23:59 am 17.10., nicht heute + buchung('p1', { startsAt: '2026-10-17T22:15:00.000Z' }) // 00:15 am 18.10., zählt, vorbei + buchung('p1', { startsAt: '2026-10-18T06:00:00.000Z' }) // 08:00, nächster + buchung('p1', { startsAt: '2026-10-18T22:00:00.000Z' }) // 00:00 am 19.10., nicht heute + assert.equal(info(await holen(), 'p1', 'termine'), '2 Termine heute · nächster 08:00') +}) + +/* ------------------------------------------------------------------------------------------ Webshop --- */ +test('Webshop: neue (bezahlte, noch nicht bearbeitete) Bestellungen dieses Shops', async () => { + funktion('p1', 'shop'); projekt('p2'); funktion('p2', 'shop') + assert.equal(info(await holen(), 'p1', 'shop'), 'Keine offenen Bestellungen') + bestellung('p1', 'bezahlt'); bestellung('p1', 'bezahlt') + bestellung('p1', 'versendet'); bestellung('p1', 'offen'); bestellung('p1', 'abgeschlossen'); bestellung('p1', 'storniert') + bestellung('p2', 'bezahlt') + let b = await holen() + assert.equal(info(b, 'p1', 'shop'), '2 neue Bestellungen') + assert.equal(info(b, 'p2', 'shop'), '1 neue Bestellung') +}) + +/* ------------------------------------------------------------------------------------------ News --- */ +test('News-Pinnwand: sichtbare Beiträge (nicht abgenommen, begonnen, nicht abgelaufen) dieser Website', async () => { + funktion('p1', 'news'); projekt('p2'); funktion('p2', 'news') + assert.equal(info(await holen(), 'p1', 'news'), 'Keine Beiträge sichtbar') + zettel('p1') // ohne Ende + zettel('p1', { visibleFrom: inStd(-24), visibleUntil: inStd(72) }) + zettel('p1', { visibleFrom: inStd(-240), visibleUntil: inStd(1) }) + zettel('p1', { visibleFrom: inStd(-120), visibleUntil: inStd(480) }) + zettel('p1', { visibleFrom: inStd(-240), visibleUntil: inStd(-1) }) // abgelaufen + zettel('p1', { takenDownAt: inStd(-2) }) // abgenommen + zettel('p1', { visibleFrom: inStd(24) }) // beginnt erst morgen + zettel('p2') + const b = await holen() + assert.equal(info(b, 'p1', 'news'), '4 Beiträge sichtbar') + assert.equal(info(b, 'p2', 'news'), '1 Beitrag sichtbar') +}) + +/* ------------------------------------------------------------------------------------------ Buchungskalender --- */ +test('Buchungskalender: offene Anfragen, sonst die nächste Anreise, sonst keine offenen Anfragen', async () => { + funktion('p1', 'unterkunft'); projekt('p2'); funktion('p2', 'unterkunft') + assert.equal(info(await holen(), 'p1', 'unterkunft'), 'Keine offenen Anfragen') + anfrage('p1', 'gebucht', '2026-11-02', '2026-11-05') + anfrage('p1', 'bezahlt', '2026-10-24', '2026-10-27') + anfrage('p1', 'gebucht', '2026-10-01', '2026-10-05') // vorbei + anfrage('p1', 'gebucht', '2026-10-15', '2026-10-19') // läuft schon: keine Anreise mehr + anfrage('p1', 'freigegeben', '2026-10-20', '2026-10-22') // noch nicht gebucht + anfrage('p1', 'abgelehnt', '2026-10-18', '2026-10-20') + assert.equal(info(await holen(), 'p1', 'unterkunft'), 'Nächste Anreise 24.10.') + anfrage('p1', 'neu', '2026-12-01', '2026-12-03') + anfrage('p2', 'neu', '2026-12-01', '2026-12-03') + anfrage('p2', 'neu', '2026-12-05', '2026-12-07') + const b = await holen() + assert.equal(info(b, 'p1', 'unterkunft'), '1 offene Anfrage') + assert.equal(info(b, 'p2', 'unterkunft'), '2 offene Anfragen') +}) + +test('Buchungskalender: Anreise heute und morgen werden ausgeschrieben', async () => { + funktion('p1', 'unterkunft') + anfrage('p1', 'gebucht', '2026-10-18', '2026-10-20') + assert.equal(info(await holen(), 'p1', 'unterkunft'), 'Anreise morgen') + anfrage('p1', 'bezahlt', '2026-10-17', '2026-10-19') + assert.equal(info(await holen(), 'p1', 'unterkunft'), 'Anreise heute') +}) + +/* ------------------------------------------------------------------------------------------ Öffnungszeiten --- */ +test('Öffnungszeiten live: der Status der Website von jetzt (ohne Zusatzabfrage), ohne Zeiten ein Hinweis', async () => { + funktion('p1', 'zeiten') + assert.equal(info(await holen(), 'p1', 'zeiten'), 'Noch keine Öffnungszeiten eingetragen') + zeitenDok('p1', zeitenDaten({ 6: ['09:00', '18:00'], 1: ['09:00', '18:00'] })) + resetZeitenCacheForTests() + aw.resetListCalls() + assert.equal(info(await holen(), 'p1', 'zeiten'), 'Jetzt geöffnet · schließt um 18 Uhr') + einschaltenDeps.jetzt = () => new Date('2026-10-17T18:00:00.000Z') // Samstag 20:00 + assert.equal(info(await holen(), 'p1', 'zeiten'), 'Geschlossen · öffnet Montag um 9 Uhr') +}) + +/* ------------------------------------------------------------------------------------------ Regeln --- */ +test('nur aktive Funktionen bekommen einen Text und eine Abfrage; alle anderen null', async () => { + for (const [key, state] of [['news', 'setup'], ['termine', 'ordered'], ['shop', 'disabled'], ['unterkunft', 'off'], ['zeiten', 'setup']]) funktion('p1', key, state) + buchung('p1', { startsAt: '2026-10-17T08:30:00.000Z' }) + bestellung('p1', 'bezahlt') + zettel('p1') + anfrage('p1', 'neu', '2026-12-01', '2026-12-03') + zeitenDok('p1', zeitenDaten({ 6: ['09:00', '18:00'] })) + aw.resetListCalls() + const b = await holen() + for (const key of NAMEN) assert.equal(info(b, 'p1', key), null, key) + for (const key of NAMEN) assert.ok(Object.hasOwn(b.projects[0].features[key], 'kurzinfo'), `${key}: Feld da`) + for (const sammlung of ['termineBookings', 'shopOrders', 'newsPosts', 'ukAnfragen']) assert.equal(abfragen(sammlung).length, 0, sammlung) +}) + +test('eine kleine feste Zahl schmaler Abfragen je aktiver Funktion: limit gesetzt, nur nötige Felder, nur Filter dieser Funktion', async () => { + alleAktiv('p1') + const gesamt = () => ['termineBookings', 'shopOrders', 'newsPosts', 'ukAnfragen'].map((s) => [s, abfragen(s)]) + aw.resetListCalls() + await holen() + const [termine, shop, news, uk] = gesamt().map(([, a]) => a) + assert.equal(termine.length, 1) + assert.equal(shop.length, 1) + assert.equal(news.length, 1) + assert.equal(uk.length, 2) + const hat = (aufruf, methode) => aufruf.queries.find((q) => q.method === methode) + for (const aufruf of [...termine, ...news, ...uk]) { + const limit = hat(aufruf, 'limit') + assert.ok(limit && limit.values[0] <= 200, `limit ${JSON.stringify(limit)}`) + } + assert.deepEqual(hat(shop[0], 'limit').values, [1]) // nur die Anzahl (total) + assert.ok(hat(termine[0], 'select') && hat(news[0], 'select')) + assert.deepEqual(hat(termine[0], 'equal').values, ['f_p1_termine']) + assert.deepEqual(hat(shop[0], 'equal').values, ['f_p1_shop']) + assert.deepEqual(hat(news[0], 'equal').values, ['p1']) + for (const aufruf of uk) assert.deepEqual(hat(aufruf, 'equal').values, ['f_p1_unterkunft']) + assert.equal(aw.listCalls('oeffnungszeiten').length <= 1, true) // Zeiten: höchstens das Lesen für zeitenStatus, nichts zusätzlich +}) + +test('mehrere Websites: jede bekommt ihre eigene Zeile, die Abfragen laufen für alle gleichzeitig', async () => { + alleAktiv('p1'); projekt('p2'); alleAktiv('p2'); projekt('p3'); funktion('p3', 'termine') + buchung('p1', { startsAt: '2026-10-17T08:30:00.000Z' }) + buchung('p3', { startsAt: '2026-10-17T07:00:00.000Z' }) + buchung('p3', { startsAt: '2026-10-17T13:00:00.000Z' }) + const b = await holen() + assert.deepEqual(['p1', 'p2', 'p3'].map((p) => info(b, p, 'termine')).sort(), ['1 Termin heute · nächster 10:30', '2 Termine heute · nächster 15:00', 'Heute keine Termine'].sort()) + assert.equal(info(b, 'p3', 'shop'), null) +}) + +test('Fehler einer Einzelabfrage ergibt null für diese Funktion; der Rest der Liste bleibt', async () => { + alleAktiv('p1') + buchung('p1', { startsAt: '2026-10-17T08:30:00.000Z' }) + bestellung('p1', 'bezahlt') + zettel('p1') + aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500, message: 'geheimer Gästename im Fehlertext' }) + const b = await holen() + assert.equal(info(b, 'p1', 'termine'), null) + assert.equal(info(b, 'p1', 'shop'), '1 neue Bestellung') + assert.equal(info(b, 'p1', 'news'), '1 Beitrag sichtbar') + assert.equal(b.modell, 'abo') + assert.ok(Array.isArray(b.katalog)) + const log = console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n') + assert.match(log, /Kurzinfo \(termine\)/) + assert.doesNotMatch(log, /geheimer/) + // ein Teilfehler im Buchungskalender (eine der zwei Abfragen) ergibt null, nie eine halbe Aussage + anfrage('p1', 'neu', '2026-12-01', '2026-12-03') + aw.failNext({ collection: 'ukAnfragen', method: 'GET', status: 500 }) + assert.equal(info(await holen(), 'p1', 'unterkunft'), null) +}) + +test('Zeitüberschreitung ergibt null, ohne die Liste aufzuhalten', async () => { + alleAktiv('p1') + bestellung('p1', 'bezahlt'); zettel('p1') + kurzinfoDeps.limitMs = 60 + const echt = globalThis.fetch + mock.method(globalThis, 'fetch', async (url, ...rest) => { + if (String(url).includes('/shopOrders/')) await new Promise((r) => setTimeout(r, 600)) + return echt(url, ...rest) + }) + const start = Date.now() + const b = await holen() + assert.ok(Date.now() - start < 550, `Antwort dauerte ${Date.now() - start} ms`) + assert.equal(info(b, 'p1', 'shop'), null) + assert.equal(info(b, 'p1', 'news'), '1 Beitrag sichtbar') + await new Promise((r) => setTimeout(r, 700)) // die späte Antwort richtet nichts mehr an +}) + +test('ohne neue Ansicht: keine Kurzinfo in der Antwort, keine einzige Abfrage dafür; sonst ist die neue Antwort die alte plus ergänzte Felder', async () => { + alleAktiv('p1') + buchung('p1', { startsAt: '2026-10-17T08:30:00.000Z' }); bestellung('p1', 'bezahlt'); zettel('p1'); anfrage('p1', 'neu', '2026-12-01', '2026-12-03') + config.kundenansichtNeu.mode = 'off' + aw.resetListCalls() + const alt = await holen() + assert.doesNotMatch(JSON.stringify(alt), /kurzinfo/) + for (const sammlung of ['termineBookings', 'shopOrders', 'newsPosts', 'ukAnfragen']) assert.equal(abfragen(sammlung).length, 0, sammlung) + config.kundenansichtNeu.mode = 'pilot' + config.kundenansichtNeu.pilotEmails = ['k2@example.invalid'] // Kunde nicht in der Liste + assert.deepEqual(await holen(), alt) + config.kundenansichtNeu.pilotEmails = ['k@example.invalid'] + const neu = await holen() + for (const key of ['modell', 'kundenart', 'abo', 'katalog']) delete neu[key] + for (const p of neu.projects) for (const f of Object.values(p.features)) { delete f.kurzinfo; delete f.anfrageStatus } + assert.deepEqual(neu, alt) +}) + +test('die Kurzinfo ist ein kurzer Text ohne Preis, Mail-Adresse oder Namen von Gästen', async () => { + alleAktiv('p1') + buchung('p1', { startsAt: '2026-10-17T08:30:00.000Z', gastName: 'Erika Beispiel', gastEmail: 'erika@example.invalid', preisCent: 4500 }) + bestellung('p1', 'bezahlt', { kaeuferEmail: 'kauf@example.invalid', summeCent: 9900 }) + zettel('p1', { title: 'Geheimer Titel' }) + anfrage('p1', 'neu', '2026-12-01', '2026-12-03', { gast: JSON.stringify({ name: 'Max Gast' }) }) + const b = await holen() + const texte = NAMEN.map((k) => info(b, 'p1', k)) + for (const t of texte) { + assert.equal(typeof t, 'string') + assert.ok(t.length > 0 && t.length <= 60, t) + assert.doesNotMatch(t, /€|Euro|@|Erika|Max|Geheimer|\d+,\d\d/) + } +})