From 11dc4b2c9fd46e5d3a036bf937f1ed6a14252e4f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:28:52 +0000 Subject: [PATCH] =?UTF-8?q?=C3=96ffnungszeiten:=20Freischalten=20erst=20mi?= =?UTF-8?q?t=20eingetragenen=20Zeiten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der 5-Minuten-Job setzt Öffnungszeiten live nach dem Schließen des Tickets nur noch auf active, wenn der Kunde Zeiten eingetragen hat (Haken freischaltbar: Hauptplan gespeichert, mindestens ein Tag nicht "zu"; Lesefehler ergeben false, das Dokument bleibt in setup). Neuer Register-Haken nachFreischaltung: runActivation ruft ihn je freigeschaltetem Dokument vor der K16 auf, Fehler werden nur geloggt; für die Zeiten merkt er den Ersatz-Hash (wochenplanHash). Selbstheilung (Spec 3.2): im Lauf des Jobs (nicht in der Projektliste mit customerId) holt holeZeitenNach für Zeiten in setup fehlendes Ticket und K24 nach, je Dokument abgesichert und ohne Kundendaten im Log. Co-Authored-By: Claude Opus 5.5 --- server/services/featureRegistry.js | 42 +++- server/services/siteFeatures.js | 44 +++- test/featureRegistry.test.js | 4 +- test/zeitenFreischalten.test.js | 390 +++++++++++++++++++++++++++++ test/zeitenRegister.test.js | 7 +- 5 files changed, 476 insertions(+), 11 deletions(-) create mode 100644 test/zeitenFreischalten.test.js diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index 489a8ce..27c8b0f 100644 --- a/server/services/featureRegistry.js +++ b/server/services/featureRegistry.js @@ -7,8 +7,8 @@ import { zeitenFuerKunde } from './zeiten/schalter.js' // termine/schalter.js und zeiten/schalter.js, die nur config lesen): Standard-Haken laden ihre Dienste per dynamischem import() // innerhalb der Funktion (sonst entstuenden Importzyklen). -/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */ -const HAKEN_NAMEN = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar'] +/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen, dazu nachFreischaltung. */ +const HAKEN_NAMEN = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar', 'nachFreischaltung'] /** * Bestellpruefung des Terminplaners im Checkout: die offenen Punkte des Entwurfs (Einstellung und Fragebogen aus @@ -38,6 +38,35 @@ async function termineFreischaltbar(doc) { return (await ladeEinstellung(doc.$id)) !== null } +/** + * Freischaltpruefung der Oeffnungszeiten (Spec 4.4): die Einrichtung ist erst freizugeben, wenn der Kunde Zeiten eingetragen hat, also + * ein Hauptplan gespeichert ist und mindestens ein Wochentag nicht "zu" ist (modusUndFenster: auch ein unlesbarer Eintrag zaehlt als zu). + * Fail-closed: jeder Fehler (Lesefehler, kaputte Daten) ergibt false und bleibt im Log (ohne Kundendaten), das Dokument bleibt in + * 'setup' und der naechste Lauf des 5-Minuten-Jobs fragt erneut. Der Aufrufer uebergibt das unter der Sperre frisch gelesene Dokument. + */ +async function zeitenFreischaltbar(doc) { + try { + const [{ ladeZeiten }, { hauptplan, modusUndFenster }] = await Promise.all([import('./zeiten/speicher.js'), import('./zeiten/zeitlogik.js')]) + const haupt = hauptplan((await ladeZeiten(doc.$id))?.daten ?? null) + if (!haupt) return false + return [1, 2, 3, 4, 5, 6, 7].some((tag) => modusUndFenster(haupt.tage?.[tag]).modus !== 'zu') + } catch (err) { + console.error(`[funktionen] Öffnungszeiten ${doc?.$id}: Zeiten für die Freischaltung nicht prüfbar:`, err.message) + return false + } +} + +/** + * Nach der Freischaltung der Oeffnungszeiten (Spec 4.4): den Hash des Hauptplan-Wochenplans als ersatzHash merken. Das Ersatz-HTML, + * das das Team beim Einbau setzt, zeigt diesen Wochenplan; weicht er beim Abschalten ab, entsteht das Ticket "Ersatz-Tabelle + * aktualisieren". setzeErsatzHash nimmt die Sperre zeiten: selbst (hier nicht halten) und fasst Daten und Versionen nicht an. + * Wirft bei Lese- und Schreibfehlern; der Aufrufer (runActivation) loggt und laesst Freischaltung und K16 davon unberuehrt. + */ +async function zeitenNachFreischaltung(doc) { + const [{ ladeZeiten, setzeErsatzHash }, { wochenplanHash }] = await Promise.all([import('./zeiten/speicher.js'), import('./zeiten/ersatz.js')]) + await setzeErsatzHash(doc, wochenplanHash((await ladeZeiten(doc.$id))?.daten ?? null)) +} + /** * Platzhalter der Widerrufsbelehrung mit Muster-Widerrufsformular, die die Bestellseite vor dem Knopf zeigt und der * Zustimmungsnachweis speichert. Block F (Task F7) ersetzt den Haken belehrung durch widerrufsbelehrungFunktion(). @@ -60,12 +89,13 @@ const STANDARD_HAKEN = { freischaltbar: termineFreischaltbar, }, // Oeffnungszeiten live: keine Bestellpruefung (die Einstellung entsteht erst im Editor); belehrung und - // unterlagenBereit wie beim Terminplaner; freischaltbar ist ein Platzhalter (Task 18 ersetzt ihn) + // unterlagenBereit wie beim Terminplaner; freischaltbar: erst mit eingetragenen Zeiten; nachFreischaltung: Ersatz-Hash merken zeiten: { verfuegbar: (email) => zeitenFuerKunde(email), unterlagenBereit: async () => true, belehrung: async () => BELEHRUNG_PLATZHALTER, - freischaltbar: async () => true, + freischaltbar: zeitenFreischaltbar, + nachFreischaltung: zeitenNachFreischaltung, }, } @@ -89,10 +119,12 @@ export function ticketFreischaltungKeys() { } /** - * Haken einer Funktion: { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar? }. + * Haken einer Funktion: { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar?, nachFreischaltung? }. * Wer config.features.. als Funktion setzt (Tests), ueberschreibt den Standard. * freischaltbar(doc) => Promise: darf das Dokument (Zustand 'setup', Ticket geschlossen) jetzt aktiv werden? * Nur true gibt frei; false und jeder Fehler halten es in 'setup' (siehe activateClosedTickets). + * nachFreischaltung(doc) => Promise: Nacharbeit nach der Freischaltung (doc ist das aktive Dokument); runActivation ruft sie + * vor der K16 und faengt jeden Fehler (Log), Freischaltung und K16 haengen nie davon ab. */ export function featureHaken(key) { const eintrag = featureConfig(key) diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 9686520..cc859dc 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -1037,10 +1037,51 @@ export async function handleFeatureCheckoutEvent(event) { } } -/** Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active', K16 an jeden betroffenen Kunden. */ +/** + * Selbstheilung der Oeffnungszeiten (Spec 3.2): fuer jedes Dokument der Zeiten in 'setup' holt holeZeitenNach fehlende Schritte + * nach (Neubau, Einbau-Ticket, K24; es sperrt je Dokument selbst und fasst Kauf, aktiv und aus nicht an). Hierher und nicht in + * activateClosedTickets: dort zaehlen nur Dokumente MIT Ticket. Jedes Dokument fuer sich abgesichert (ein Fehler stoppt weder die + * uebrigen noch den Job; der naechste Lauf versucht es erneut), das Log nennt Dokument-ID und Fehler, nie Kundendaten. + * Dynamischer Import: zeiten/* importiert dieses Modul. + */ +async function heileZeiten({ now }) { + if (!featureConfig('zeiten')) return + let docs + try { + docs = await listDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'zeiten'), Query.equal('state', 'setup'), Query.limit(1000)]) + } catch (err) { + console.error('[funktionen] Selbstheilung Öffnungszeiten: Dokumente nicht lesbar:', err.message) + return + } + if (docs.length === 0) return + const { holeZeitenNach } = await import('./zeiten/einschalten.js') + for (const doc of docs) { + try { + await holeZeitenNach(doc, { now }) + } catch (err) { + console.error(`[funktionen] Selbstheilung Öffnungszeiten (${doc.$id}) fehlgeschlagen:`, [err?.name ?? 'Fehler', err?.status, err?.message].filter(Boolean).join(' ')) + } + } +} + +/** + * Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active' (je Funktion nur, wenn ihr Haken freischaltbar es erlaubt; Oeffnungszeiten: + * erst mit eingetragenen Zeiten), danach je Dokument der Haken nachFreischaltung (Oeffnungszeiten: Ersatz-Hash) und K16 an jeden + * betroffenen Kunden. Zum Schluss, nur im Lauf des Jobs (ohne customerId), die Selbstheilung der Oeffnungszeiten (heileZeiten): die + * Projektliste eines Kunden (GET /api/funktionen, mit customerId) schaltet nur frei und baut, bestellt und sendet nichts nach. + */ export async function runActivation({ now = new Date(), customerId = null } = {}) { const activated = await activateClosedTickets({ now, customerId }) for (const doc of activated) { + // Nacharbeit vor der K16 und vor dem Adress-Check: auch ein Kunde ohne Adresse bekommt sie; ein Fehler bricht nichts ab + const { nachFreischaltung } = featureHaken(doc.featureKey) + if (typeof nachFreischaltung === 'function') { + try { + await nachFreischaltung(doc) + } catch (err) { + console.error(`[funktionen] Nacharbeit nach der Freischaltung ${doc.featureKey} (${doc.$id}) fehlgeschlagen:`, err.message) + } + } try { const customer = await customerOf(doc.customerId) if (!customer?.email) continue @@ -1054,5 +1095,6 @@ export async function runActivation({ now = new Date(), customerId = null } = {} console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message) } } + if (!customerId) await heileZeiten({ now }) return activated } diff --git a/test/featureRegistry.test.js b/test/featureRegistry.test.js index 3ac8f4f..085a570 100644 --- a/test/featureRegistry.test.js +++ b/test/featureRegistry.test.js @@ -155,8 +155,8 @@ test('Haken überschreibbar: config.features.. gewinnt gegen den Sta } assert.deepEqual(reg.featureHaken('termine'), vorherTermine) - // jeder der fünf Haken lässt sich setzen; Nicht-Funktionen und fremde Namen zählen nicht - const namen = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar'] + // jeder der sechs Haken lässt sich setzen; Nicht-Funktionen und fremde Namen zählen nicht + const namen = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar', 'nachFreischaltung'] const news = config.features.news try { for (const name of namen) news[name] = () => name diff --git a/test/zeitenFreischalten.test.js b/test/zeitenFreischalten.test.js new file mode 100644 index 0000000..fd6d47b --- /dev/null +++ b/test/zeitenFreischalten.test.js @@ -0,0 +1,390 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.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' + +// Freischalten von Oeffnungszeiten live (Aufgabe 18, Spec 3.2 Selbstheilung und 4.4): der 5-Minuten-Job (runActivation) setzt 'active', +// wenn das Einbau-Ticket geschlossen ist UND der Kunde Zeiten eingetragen hat (Haken freischaltbar: Hauptplan gespeichert, mindestens +// ein Tag nicht "zu"). Bei active: K16 und der Ersatz-Hash (Haken nachFreischaltung). Dazu heilt der Job Dokumente in setup, denen +// Ticket oder K24 fehlen (holeZeitenNach), je Dokument getrennt abgesichert. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + }, + datetimes: { + ...ZEITEN_DATETIMES, + portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], + }, + defaultLimit: 25, +}) +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 { setMailTransportForTests } = await import('../server/services/mailer.js') +const reg = await import('../server/services/featureRegistry.js') +const sf = await import('../server/services/siteFeatures.js') +const { zeitenDeps } = await import('../server/services/zeiten/neubau.js') +const { ladeZeiten, speichereZeiten } = await import('../server/services/zeiten/speicher.js') +const { wochenplanHash } = await import('../server/services/zeiten/ersatz.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') + +after(() => aw.close()) +after(() => setMailTransportForTests(null)) + +const Z = config.features.zeiten +const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin +const K16_BETREFF = /bereit/ +const K24_BETREFF = 'Öffnungszeiten live ist eingeschaltet' + +const feld = (id, name) => aw.docs('portalFeatures').get(id)[name] +const zustand = (id) => feld(id, 'state') +const tickets = () => [...aw.docs('workorders').values()] +const k16 = () => sink.sent.filter((m) => K16_BETREFF.test(m.subject)) +const k24 = () => sink.sent.filter((m) => m.subject === K24_BETREFF) +const logText = () => console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n') + +function tage(modi) { + const t = {} + for (let d = 1; d <= 7; d += 1) { + const modus = modi[d] ?? 'zu' + t[d] = { modus, fenster: modus === 'zeiten' ? [['09:00', '18:00']] : [] } + } + return t +} +const WOCHE = { 1: 'zeiten', 2: 'zeiten', 3: 'zeiten', 4: 'zeiten', 5: 'zeiten' } +const eingabe = (modi = WOCHE) => ({ + land: 'RP', + plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage: tage(modi) }], + feiertage: {}, + ausnahmen: [], + hinweise: [], + kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, +}) + +let nr = 0 +/** + * Dokument der Zeiten im Zustand setup mit Projekt, Kunde und Ticket (Standard: geschlossen), optional mit gespeicherten Zeiten + * (zeiten: Wochenplan wie in tage()). Standard: abo, einbau gesetzt und k24At gesetzt, damit nur die Freischaltung etwas bewegt. + */ +async function einrichtung({ customerId = 'c1', ticket = 'Closed', zeiten = null, ...over } = {}) { + nr += 1 + const projectId = `p${nr}` + const id = `f_${projectId}_zeiten` + aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt ${nr}`, subdomain: projectId, repoFullName: `JUSN/${projectId}`, previewMode: 'auto', previewDetected: 'static' }) + if (ticket) aw.seed('workorders', { $id: `t_${projectId}`, woid: `WO-${nr}`, status: ticket, topic: 'x' }) + aw.seed('portalFeatures', { + $id: id, + customerId, + projectId, + featureKey: 'zeiten', + state: 'setup', + enabled: false, + siteKey: 'abcdefghijklmnop', + unlockedBy: 'abo', + einbau: 'angestossen', + k24At: JETZT.toISOString(), + ...(ticket ? { ticketId: `t_${projectId}` } : {}), + ...over, + }) + if (zeiten) await speichereZeiten({ feature: aw.docs('portalFeatures').get(id), eingabe: eingabe(zeiten), version: 0, now: JETZT }) + return id +} + +let sink +let rebuilds +const originalRebuild = zeitenDeps.rebuild +beforeEach(async () => { + for (const col of ['portalFeatures', 'workorders', 'websiteProjects', 'oeffnungszeiten', 'customers']) { + for (const id of aw.docs(col).keys()) await deleteDocument(col, id) + } + resetZeitenCacheForTests() + aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }) + aw.seed('customers', { $id: 'c2', appwriteUserId: 'u2', email: 'k2@example.invalid', name: 'Kunde Zwei', status: 'active', portalAccessEnabled: true }) + for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'freischaltbar', 'nachFreischaltung']) delete Z[name] + sink = mailSink() + setMailTransportForTests(sink) + rebuilds = [] + zeitenDeps.rebuild = async (p) => { + rebuilds.push(p.$id) + return { queued: true } + } + mock.method(console, 'error', () => {}) + mock.method(console, 'warn', () => {}) + mock.method(console, 'log', () => {}) +}) +afterEach(() => { + mock.restoreAll() + zeitenDeps.rebuild = originalRebuild +}) + +// ------------------------------------------------------------------------------------------------ Freischalten + +test('Ticket zu, aber keine Zeiten ⇒ bleibt setup, kein K16, kein Ersatz-Hash', async () => { + const id = await einrichtung() + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(zustand(id), 'setup') + assert.equal(feld(id, 'enabled'), false) + assert.equal(sink.sent.length, 0) + assert.equal(await ladeZeiten(id), null, 'ohne Zeiten entsteht auch kein Zeiten-Dokument') + // jeder weitere Lauf ändert nichts; die Wartenotiz steht genau einmal (kein Schreiben alle 5 Minuten) + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(zustand(id), 'setup') + assert.equal(String(feld(id, 'adminNote')).split(' | ').filter((e) => e.includes('Freischaltung wartet')).length, 1) +}) + +test('Ticket zu, Zeiten gespeichert, aber alle Tage „zu“ ⇒ bleibt setup', async () => { + const id = await einrichtung({ zeiten: {} }) + assert.ok((await ladeZeiten(id)).daten, 'die Zeiten sind gespeichert') + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(zustand(id), 'setup') + assert.equal(sink.sent.length, 0) + assert.equal((await ladeZeiten(id)).ersatzHash, '') +}) + +test('Zeiten ohne Hauptplan (nur ein Nebenplan) oder ohne Pläne ⇒ bleibt setup', async () => { + const nebenplan = await einrichtung() + aw.seed('oeffnungszeiten', { + $id: nebenplan, customerId: 'c1', projectId: aw.docs('portalFeatures').get(nebenplan).projectId, featureId: nebenplan, version: 1, teamVersion: 0, + daten: JSON.stringify({ land: 'RP', plaene: [{ id: 'p_bbbbbbb1', name: 'Nebenplan', haupt: false, tage: tage(WOCHE) }] }), + }) + const leer = await einrichtung() + aw.seed('oeffnungszeiten', { + $id: leer, customerId: 'c1', projectId: aw.docs('portalFeatures').get(leer).projectId, featureId: leer, version: 1, teamVersion: 0, + daten: JSON.stringify({ land: 'RP', plaene: [] }), + }) + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.deepEqual([zustand(nebenplan), zustand(leer)], ['setup', 'setup']) + assert.equal(sink.sent.length, 0) +}) + +test('Ticket zu und Zeiten gespeichert ⇒ active, genau eine K16, ersatzHash gesetzt', async () => { + const id = await einrichtung({ zeiten: WOCHE }) + const vorher = await ladeZeiten(id) + assert.equal(vorher.ersatzHash, '') + + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id), [id]) + assert.equal(zustand(id), 'active') + assert.equal(feld(id, 'enabled'), true) + assert.equal(Date.parse(feld(id, 'activatedAt')), JETZT.getTime()) + assert.equal(k16().length, 1) + assert.deepEqual(k16()[0].to.map((a) => a.address), ['k@example.invalid']) + assert.equal(k24().length, 0) + + const nachher = await ladeZeiten(id) + assert.match(nachher.ersatzHash, /^[0-9a-f]{16}$/) + assert.equal(nachher.ersatzHash, wochenplanHash(vorher.daten)) + // der Ersatz-Hash fasst weder Daten noch Versionen an: ein offener Editor wird nicht veraltet + assert.deepEqual([nachher.version, nachher.teamVersion, nachher.daten], [vorher.version, vorher.teamVersion, vorher.daten]) + + // ein zweiter Lauf: nichts mehr zu tun, keine zweite Mail + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(k16().length, 1) +}) + +test('ein einziger Tag, der nicht „zu“ ist, reicht (auch „rund“ oder „nach Vereinbarung“)', async () => { + const rund = await einrichtung({ zeiten: { 7: 'rund' } }) + const vereinbarung = await einrichtung({ zeiten: { 3: 'vereinbarung' } }) + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id).sort(), [rund, vereinbarung].sort()) + assert.equal(k16().length, 2) +}) + +test('Ticket noch offen ⇒ bleibt setup, auch mit Zeiten (der Job schaltet nur nach dem Schließen frei)', async () => { + const id = await einrichtung({ ticket: 'Open', zeiten: WOCHE }) + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(zustand(id), 'setup') + assert.equal(sink.sent.length, 0) + assert.equal((await ladeZeiten(id)).ersatzHash, '') +}) + +test('Zeiten kommen nach dem Schließen des Tickets ⇒ der nächste Lauf schaltet frei', async () => { + const id = await einrichtung() + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + await speichereZeiten({ feature: aw.docs('portalFeatures').get(id), eingabe: eingabe(), version: 0, now: JETZT }) + assert.deepEqual((await sf.runActivation({ now: JETZT })).map((d) => d.$id), [id]) + assert.equal(zustand(id), 'active') + assert.equal(k16().length, 1) +}) + +test('Lesefehler im Haken ⇒ bleibt setup, kein Wurf, kein K16; der nächste Lauf schaltet frei', async () => { + const id = await einrichtung({ zeiten: WOCHE }) + aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500, message: 'Appwrite down' }) + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(zustand(id), 'setup') + assert.equal(sink.sent.length, 0) + assert.equal(feld(id, 'enabled'), false) + + assert.deepEqual((await sf.runActivation({ now: JETZT })).map((d) => d.$id), [id]) + assert.equal(zustand(id), 'active') +}) + +test('Haken freischaltbar der Zeiten: Hauptplan mit offenem Tag ⇒ true, sonst false, Lesefehler ⇒ false (fail-closed)', async () => { + const { freischaltbar } = reg.featureHaken('zeiten') + assert.equal(typeof freischaltbar, 'function') + const offen = await einrichtung({ zeiten: { 2: 'zeiten' } }) + const zu = await einrichtung({ zeiten: {} }) + const ohne = await einrichtung() + assert.equal(await freischaltbar(aw.docs('portalFeatures').get(offen)), true) + assert.equal(await freischaltbar(aw.docs('portalFeatures').get(zu)), false) + assert.equal(await freischaltbar(aw.docs('portalFeatures').get(ohne)), false) + assert.equal(await freischaltbar({ $id: 'gibt-es-nicht' }), false) + aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500 }) + assert.equal(await freischaltbar(aw.docs('portalFeatures').get(offen)), false) +}) + +// ----------------------------------------------------------------------------------------------- Ersatz-Hash + +test('nachFreischaltung scheitert (Ersatz-Hash nicht schreibbar) ⇒ trotzdem active, K16 geht raus, Fehler im Log', async () => { + const id = await einrichtung({ zeiten: WOCHE }) + aw.failNext({ collection: 'oeffnungszeiten', method: 'PATCH', status: 500, message: 'Schreiben kaputt' }) + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id), [id]) + assert.equal(zustand(id), 'active') + assert.equal(k16().length, 1) + assert.equal((await ladeZeiten(id)).ersatzHash, '', 'nicht gesetzt') + assert.match(logText(), /Schreiben kaputt/) +}) + +test('nachFreischaltung: nur Zeiten haben den Haken (News und Terminplaner nicht)', () => { + assert.equal(typeof reg.featureHaken('zeiten').nachFreischaltung, 'function') + assert.equal(reg.featureHaken('news').nachFreischaltung, undefined) + assert.equal(reg.featureHaken('termine').nachFreischaltung, undefined) +}) + +test('nachFreischaltung des Registers wird je freigeschaltetem Dokument mit dem aktiven Dokument gerufen; ein Wurf stoppt die übrigen nicht', async () => { + const a = await einrichtung({ zeiten: WOCHE }) + const b = await einrichtung({ zeiten: WOCHE }) + const gerufen = [] + Z.nachFreischaltung = async (doc) => { + gerufen.push([doc.$id, doc.state]) + if (doc.$id === a) throw new Error('Haken kaputt') + } + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id).sort(), [a, b].sort()) + assert.deepEqual(gerufen.sort(), [[a, 'active'], [b, 'active']].sort()) + assert.equal(k16().length, 2, 'beide Kunden bekommen ihre K16') + assert.match(logText(), /Haken kaputt/) +}) + +// ------------------------------------------------------------------------------------------- Selbstheilung + +test('Selbstheilung: setup ohne ticketId ⇒ der Job legt das Ticket an (und sendet die K24)', async () => { + const id = await einrichtung({ ticket: null, einbau: '', k24At: null }) + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(zustand(id), 'setup') + assert.equal(tickets().length, 1) + assert.equal(feld(id, 'ticketId'), tickets()[0].$id) + assert.match(tickets()[0].topic, /^Öffnungszeiten live einbauen – Projekt \d+$/) + assert.equal(feld(id, 'einbau'), 'angestossen') + assert.equal(rebuilds.length, 1, 'einbau war leer: ein Neubau') + assert.equal(k24().length, 1) + assert.ok(feld(id, 'k24At')) + + // kein zweites Ticket, kein zweiter Neubau, keine zweite Mail + await sf.runActivation({ now: JETZT }) + assert.deepEqual([tickets().length, rebuilds.length, k24().length], [1, 1, 1]) +}) + +test('Selbstheilung: unlockedBy abo ohne k24At ⇒ der Job sendet die K24 genau einmal', async () => { + const id = await einrichtung({ ticket: 'Open', k24At: null }) + await sf.runActivation({ now: JETZT }) + await sf.runActivation({ now: JETZT }) + await sf.runActivation({ now: JETZT }) + assert.equal(k24().length, 1) + assert.deepEqual(k24()[0].to.map((a) => a.address), ['k@example.invalid']) + assert.equal(Date.parse(feld(id, 'k24At')), JETZT.getTime()) + assert.equal(tickets().length, 1, 'das Ticket war da: kein neues') + assert.deepEqual(rebuilds, []) + assert.equal(zustand(id), 'setup') +}) + +test('Selbstheilung: K24 nicht gesendet (Mail scheitert) ⇒ k24At bleibt leer, der nächste Lauf sendet nach', async () => { + const id = await einrichtung({ ticket: 'Open', k24At: null }) + setMailTransportForTests({ async sendMail() { throw new Error('SMTP down') } }) + await sf.runActivation({ now: JETZT }) + assert.ok(!feld(id, 'k24At')) + setMailTransportForTests(sink) + await sf.runActivation({ now: JETZT }) + assert.equal(k24().length, 1) + assert.ok(feld(id, 'k24At')) +}) + +test('Selbstheilung nur für setup und nicht für kauf; Team bekommt Ticket, aber keine K24', async () => { + const kauf = await einrichtung({ ticket: null, einbau: '', k24At: null, unlockedBy: 'kauf', unlockedByPurchase: true }) + const aktiv = await einrichtung({ ticket: null, state: 'active', enabled: true, k24At: null }) + const aus = await einrichtung({ ticket: null, state: 'off', k24At: null }) + const team = await einrichtung({ ticket: null, einbau: '', k24At: null, unlockedBy: 'team' }) + await sf.runActivation({ now: JETZT }) + assert.equal(tickets().length, 1, 'nur das Team-Dokument bekam ein Ticket') + assert.equal(tickets()[0].$id, feld(team, 'ticketId')) + assert.equal(sink.sent.length, 0) + for (const id of [kauf, aktiv, aus]) assert.ok(!feld(id, 'ticketId'), id) + assert.ok(!feld(team, 'k24At')) +}) + +test('Selbstheilung nur für Zeiten: ein Terminplaner in setup ohne Ticket bleibt unberührt', async () => { + aw.seed('websiteProjects', { $id: 'pt', customerId: 'c1', projectName: 'Termine' }) + aw.seed('portalFeatures', { $id: 'f_pt_termine', customerId: 'c1', projectId: 'pt', featureKey: 'termine', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop' }) + await sf.runActivation({ now: JETZT }) + assert.equal(tickets().length, 0) + assert.equal(sink.sent.length, 0) +}) + +test('Selbstheilung: ein Dokument scheitert (Ticketsystem down) ⇒ die übrigen werden geheilt, der Job wirft nicht, das Log nennt keine Kundendaten', async () => { + const kaputt = await einrichtung({ ticket: null, einbau: '', k24At: null }) + const heil = await einrichtung({ ticket: null, einbau: '', k24At: null, customerId: 'c2' }) + aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticketsystem down' }) + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done, []) + assert.ok(!feld(kaputt, 'ticketId')) + assert.ok(feld(heil, 'ticketId'), 'das zweite Dokument wurde trotzdem geheilt') + assert.equal(k24().length, 1) + assert.deepEqual(k24()[0].to.map((a) => a.address), ['k2@example.invalid']) + const log = logText() + assert.match(log, /Ticketsystem down/) + assert.doesNotMatch(log, /example\.invalid|Kunde Eins|Kunde Zwei/) + + // der nächste Lauf holt das gescheiterte nach + await sf.runActivation({ now: JETZT }) + assert.ok(feld(kaputt, 'ticketId')) + assert.equal(k24().length, 2) +}) + +test('Selbstheilung: Listenfehler beim Suchen der Dokumente ⇒ kein Wurf, K16 der schon freigeschalteten bleibt', async () => { + const id = await einrichtung({ zeiten: WOCHE }) + // GET auf portalFeatures: 1 Liste der Freischaltung, 2 frisches Lesen unter der Sperre, 3 Liste der Selbstheilung + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, after: 2 }) + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id), [id]) + assert.equal(k16().length, 1) + assert.match(logText(), /Selbstheilung Öffnungszeiten: Dokumente nicht lesbar/, 'der Fehler traf die Liste der Selbstheilung') +}) + +test('Projektliste eines Kunden (runActivation mit customerId) schaltet nur frei: keine Selbstheilung, kein Neubau, kein Ticket, keine Mail', async () => { + const unvollstaendig = await einrichtung({ ticket: null, einbau: '', k24At: null }) + const fertig = await einrichtung({ zeiten: WOCHE }) + const done = await sf.runActivation({ now: JETZT, customerId: 'c1' }) + assert.deepEqual(done.map((d) => d.$id), [fertig], 'Freischalten gilt weiter für den Kunden') + assert.ok(!feld(unvollstaendig, 'ticketId')) + assert.deepEqual([tickets().length, rebuilds.length, k24().length], [1, 0, 0], 'nur das geschlossene Ticket der fertigen Funktion') + assert.equal(k16().length, 1) + // der Lauf des Jobs holt es dann nach + await sf.runActivation({ now: JETZT }) + assert.ok(feld(unvollstaendig, 'ticketId')) + assert.deepEqual([tickets().length, rebuilds.length, k24().length], [2, 1, 1]) +}) + +test('Heilen und Freischalten im selben Lauf: aktiv gewordene Dokumente bekommen keine K24 mehr nachgeschoben', async () => { + const id = await einrichtung({ zeiten: WOCHE, k24At: null }) + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id), [id]) + assert.equal(zustand(id), 'active') + assert.equal(k24().length, 0) + assert.equal(k16().length, 1) +}) diff --git a/test/zeitenRegister.test.js b/test/zeitenRegister.test.js index cf08ee4..24a3813 100644 --- a/test/zeitenRegister.test.js +++ b/test/zeitenRegister.test.js @@ -104,14 +104,15 @@ test('verfuegbar folgt ZEITEN_ENABLED', () => { } }) -test('Standard-Haken: verfuegbar, unterlagenBereit, belehrung, freischaltbar (keine Bestellprüfung)', async () => { +test('Standard-Haken: verfuegbar, unterlagenBereit, belehrung, freischaltbar, nachFreischaltung (keine Bestellprüfung)', async () => { const haken = reg.featureHaken('zeiten') - assert.deepEqual(Object.keys(haken).sort(), ['belehrung', 'freischaltbar', 'unterlagenBereit', 'verfuegbar']) + assert.deepEqual(Object.keys(haken).sort(), ['belehrung', 'freischaltbar', 'nachFreischaltung', 'unterlagenBereit', 'verfuegbar']) const termine = reg.featureHaken('termine') assert.equal(await haken.unterlagenBereit(), true) assert.equal(await haken.belehrung(), await termine.belehrung(), 'wie beim Terminplaner') assert.equal(await haken.belehrung(), reg.BELEHRUNG_PLATZHALTER) - assert.equal(await haken.freischaltbar({ $id: 'x' }), true, 'Platzhalter bis Task 18') + // kein Platzhalter mehr (der alte Haken lieferte immer true): Verhalten und Nacharbeit prüft test/zeitenFreischalten.test.js + for (const name of ['freischaltbar', 'nachFreischaltung']) assert.equal(typeof haken[name], 'function', name) }) test('oeffentlicheAnsicht(zeiten) hat dieselben Schlüssel wie news', () => {