From 13523ca5e21544ab65a653b1cc6709ca8cc85cbb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 14:44:47 +0000 Subject: [PATCH] =?UTF-8?q?Abo-Abgleich:=20R=C3=BCckkehr=20innerhalb=20der?= =?UTF-8?q?=20Frist=20schaltet=20wieder=20ein=20(K23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schritt 3 von gleicheAboAb: Ein abgeschaltetes Dokument mit Grund abo_ende, dessen Wartungs-Abo wieder läuft, wird unter feature-admin: nach frischem Lesen von Dokument und Abo und mit vorhandener Einstellung über activate wieder eingeschaltet (Daten und Einstellungen bleiben). K23 geht nur, wenn K22 ging (aboEndeMailAt vor dem activate gelesen); scheitert K23, bleibt die Reaktivierung bestehen. Nicht automatisch: off nach der Löschung, abo_ende_vor_einrichtung, team, kunde_geloescht, Altbestand ohne Grund. Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/aboAbgleich.js | 81 +++++- test/termineAboAbgleich.test.js | 388 ++++++++++++++++++++++++- 2 files changed, 457 insertions(+), 12 deletions(-) diff --git a/server/services/termine/aboAbgleich.js b/server/services/termine/aboAbgleich.js index 2536813..273f4e5 100644 --- a/server/services/termine/aboAbgleich.js +++ b/server/services/termine/aboAbgleich.js @@ -3,8 +3,9 @@ import { aboLage, aboMemo } from '../aboStatus.js' import { Query, getDocument, listAllDocuments, updateDocument } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { sendCustomerMail } from '../mailer.js' -import { customerOf, disable, meldeErstattung, projectName } from '../siteFeatures.js' +import { activate, customerOf, disable, meldeErstattung, projectName } from '../siteFeatures.js' import { loeschzeit } from './frist.js' +import { ladeEinstellung } from './kontext.js' import { pruefeNow, zeitMs } from './util.js' import { datumKurz } from './zeit.js' @@ -20,6 +21,12 @@ import { datumKurz } from './zeit.js' * Schritt 2 fuer jedes abgeschaltete Dokument mit Grund 'abo_ende' oder (bezahlt) 'abo_ende_vor_einrichtung' ohne aboEndeMailAt * die Meldung nachholen (auch fuer die eben abgeschalteten); der Marker aboEndeMailAt wird erst nach erfolgreichem * Versand gesetzt + * Schritt 3 Rueckkehr: ein abgeschaltetes Dokument mit Grund 'abo_ende', dessen Wartungs-Abo wieder laeuft, wird wieder eingeschaltet + * (activate: Daten und Einstellungen bleiben, Grund, Marker und disabledAt enden), solange die Einstellung noch da ist, auch + * nach Tag 30 (der Abgleich laeuft im Tagesjob vor der Loeschung; sonst blockierten sich Loeschung und Rueckkehr). + * K23 geht nur an Kunden, die K22 bekommen haben (Marker aboEndeMailAt, vor dem activate gelesen). Nach der Loeschung ist + * das Dokument `off` und wird nie automatisch aktiv. Nicht automatisch: 'abo_ende_vor_einrichtung' (die Erstattung laeuft, + * das Team entscheidet per WOMS), 'team', 'kunde_geloescht' und Altbestand ohne Grund. * * Regeln (Block E): * - Eine Listenabfrage fuer alle Kunden (Entwuerfe `off` bleiben ungelesen), ein aboMemo() je Lauf: die Abo-Lage jedes Kunden wird @@ -41,6 +48,7 @@ const GRUND = 'abo_ende' const NOTIZ = 'Abgeschaltet: Wartungs-Abo beendet' const GRUND_VOR = 'abo_ende_vor_einrichtung' const NOTIZ_VOR = 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung' +const NOTIZ_WIEDER = 'Wieder eingeschaltet: Wartungs-Abo läuft wieder' const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g @@ -54,6 +62,9 @@ const istBewertet = (doc) => doc.state === 'setup' || (doc.state === 'disabled' && typeof doc.disabledReason === 'string' && doc.disabledReason.startsWith('abo_ende')) +/** Schritt 3: abgeschaltet wegen des Abo-Endes (nur 'abo_ende', nicht 'abo_ende_vor_einrichtung'). */ +const istRueckkehr = (doc) => doc.state === 'disabled' && doc.disabledReason === GRUND + const portalFeatures = () => config.collections.portalFeatures /** @@ -160,7 +171,7 @@ async function bearbeiteDokument(doc, ctx) { return null } const lage = await aboLage(doc.customerId) // wirft bei Lesefehler: dann bleibt alles, wie es ist - if (lage.laeuft) return null // das Abo ist zurueck (Schritt 2 und die Wiedereinschaltung gehoeren E6) + if (lage.laeuft) return null // das Abo ist zurueck: nichts mehr abzuschalten oder nachzuholen (die Rueckkehr ist Schritt 3, schalteWiederEin) let zuMelden = frisch if (aktion !== 'nachholen') { @@ -175,6 +186,59 @@ async function bearbeiteDokument(doc, ctx) { if (!fertig) erg.fehler += 1 } +/** + * K23 an die E-Mail des Kundenkontos. Nur nach geschriebenem `active` gerufen, nie innerhalb einer Sperre. Scheitert der Versand, + * bleibt die Reaktivierung bestehen: das Log (ohne Adresse) und `fehler` halten es fest; der Marker aboEndeMailAt ist mit dem + * Aktivieren geleert, es gibt also keine Wiederholung am naechsten Tag (K23 geht hoechstens einmal je Rueckkehr). Ohne Kunde oder + * ohne E-Mail-Adresse entfaellt die Mail (Hinweis im Log, kein Fehler). Wirft nie. + */ +async function sendeWiederEin(doc, ctx) { + const { erg } = ctx + try { + const customer = await customerOf(doc.customerId) + if (!customer?.email) { + console.warn('[termine-abo] K23 entfällt: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse') + return + } + await sendCustomerMail(customer.email, { + template: 'K23', + values: { + kunde_name: customer.name || '', + projekt: (await projectName(doc.projectId)) || 'Dein Projekt', + portal_url: `${config.publicBaseUrl}/dashboard.html#termine`, + }, + }) + erg.mails += 1 + } catch (err) { + erg.fehler += 1 + console.error(`[termine-abo] K23 für Funktion ${doc.$id} nicht gesendet, der Terminplaner bleibt eingeschaltet: ${fehlerText(err)}`) + } +} + +/** + * Schritt 3, ein Dokument: in der Liste abgeschaltet mit Grund 'abo_ende' und das Abo laeuft. Unter der Sperre feature-admin: + * frisch lesen (noch disabled/abo_ende: das Team kann inzwischen aktiviert oder mit anderem Grund abgeschaltet haben), frisch das + * Abo lesen (laeuft es wieder nicht, bleibt alles), die Einstellung lesen (fehlt sie, ist sie geloescht oder nie gespeichert worden: + * nie automatisch aktiv), dann activate. Ob K22 ging, zeigt der Marker aboEndeMailAt, der VOR dem activate gelesen wird (activate + * leert ihn). K23 folgt nach der Sperre und nur, wenn K22 ging; wer nie von dem Ende erfuhr, hoert auch nicht von der Rueckkehr. + * Lesefehler werfen (der Aufrufer zaehlt fehler): dann bleibt das Dokument abgeschaltet. + */ +async function schalteWiederEin(doc, ctx) { + const { erg } = ctx + const eingeschaltet = await withLocks([`feature-admin:${doc.projectId}`], async () => { + const frisch = await getDocument(portalFeatures(), doc.$id) + if (!istRueckkehr(frisch)) return null + const lage = await aboLage(doc.customerId) + if (!lage.laeuft) return null // das Abo endete zwischen Vorauswahl und Sperre wieder + if ((await ladeEinstellung(frisch.$id)) === null) return null // Daten geloescht oder nie da: Rueckkehr nur, solange sie existieren + const k22Ging = Boolean(frisch.aboEndeMailAt) + await activate(frisch.$id, { adminNote: NOTIZ_WIEDER }) + erg.wiederEin += 1 + return { k22Ging } + }) + if (eingeschaltet?.k22Ging) await sendeWiederEin(doc, ctx) +} + async function bearbeiteKunde(customerId, dokumente, ctx) { const { erg } = ctx erg.ausgenommen += dokumente.filter(istAusgenommen).length @@ -190,12 +254,13 @@ async function bearbeiteKunde(customerId, dokumente, ctx) { return } erg.geprueft += bewertet.length - if (lage.laeuft) return // laeuft weiter (E6 schaltet abgeschaltete wieder ein) for (const doc of bewertet) { - if (!aktionFuer(doc)) continue + // laeuft das Abo, gibt es nur die Rueckkehr (Schritt 3); sonst abschalten, einrichten, nachholen (Schritte 1, 1b, 2) + const bearbeite = lage.laeuft ? istRueckkehr(doc) && schalteWiederEin : aktionFuer(doc) && bearbeiteDokument + if (!bearbeite) continue try { - await bearbeiteDokument(doc, ctx) + await bearbeite(doc, ctx) } catch (err) { erg.fehler += 1 console.error(`[termine-abo] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`) @@ -209,8 +274,10 @@ async function bearbeiteKunde(customerId, dokumente, ctx) { * @param {Date} now * @returns {Promise<{ geprueft: number, abgeschaltet: number, wiederEin: number, mails: number, ausgenommen: number, fehler: number }>} * geprueft = bewertete Dokumente (aktiv mit unlockedByPurchase true, ordered/setup, disabled mit Grund abo_ende*, deren Kunde - * lesbar war), abgeschaltet = in diesem Lauf abgeschaltet, wiederEin = wieder eingeschaltet (E6; hier immer 0), mails = versendete - * K22, ausgenommen = aktive Dokumente ohne Kauf (Pilot, Kulanz), fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten + * lesbar war), abgeschaltet = in diesem Lauf abgeschaltet, wiederEin = in diesem Lauf wieder eingeschaltet (Schritt 3), mails = + * versendete Kundenmails K22 und K23 (T19 geht ans Team und zaehlt nicht), ausgenommen = aktive Dokumente ohne Kauf (Pilot, Kulanz), + * fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten, sowie ein gescheiterter K23-Versand (das Dokument ist dann + * trotzdem wieder aktiv) */ export async function gleicheAboAb(now) { pruefeNow(now, 'gleicheAboAb') diff --git a/test/termineAboAbgleich.test.js b/test/termineAboAbgleich.test.js index ef38ea6..23faa7a 100644 --- a/test/termineAboAbgleich.test.js +++ b/test/termineAboAbgleich.test.js @@ -5,9 +5,9 @@ import { mailSink } from './support/mailSink.js' import { schemaKeys } from './support/schema.js' import { useTestEnv } from './support/testEnv.js' -// Abo-Abgleich des Terminplaners (server/services/termine/aboAbgleich.js, Terminplaner TP2 E4/E5): aktive Terminplaner ohne laufendes -// Wartungs-Abo abschalten (Schritt 1), Einrichtungen, die das Abo-Ende erreicht, abschalten und erstatten (Schritt 1b, E5) und die -// Mail K22 nachholen (Schritt 2). Aufbau wie termineJobs.test.js: Nachbau von Appwrite mit Schema und Standard-Limit 25, Mails in +// Abo-Abgleich des Terminplaners (server/services/termine/aboAbgleich.js, Terminplaner TP2 E4/E5/E6): aktive Terminplaner ohne laufendes +// Wartungs-Abo abschalten (Schritt 1), Einrichtungen, die das Abo-Ende erreicht, abschalten und erstatten (Schritt 1b, E5), die +// Mail K22 nachholen (Schritt 2) und bei zurueckgekehrtem Abo wieder einschalten, mit K23 (Schritt 3, E6). Aufbau wie termineJobs.test.js: Nachbau von Appwrite mit Schema und Standard-Limit 25, Mails in // der Senke, feste Zeit JETZT. Stripe wird nie erreicht (Port 1). const aw = await startAppwriteMock({ schema: { @@ -15,6 +15,7 @@ const aw = await startAppwriteMock({ subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), termineConfig: schemaKeys('termineConfig'), + termineBookings: schemaKeys('termineBookings'), }, defaultLimit: 25, }) @@ -43,7 +44,7 @@ let sink let log beforeEach(async () => { - for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'termineConfig', 'customers', 'websiteProjects', 'workorders']) { + for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'termineConfig', 'termineBookings', 'customers', 'websiteProjects', 'workorders']) { for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) } aw.resetListCalls() @@ -536,7 +537,7 @@ for (const [fall, vorbereitung] of [ }) } -test('K22-Nachlauf: das Abo laeuft wieder - Schritt 2 uebergeben (E6), keine Mail, Marker bleibt leer', async () => { +test('K22-Nachlauf: das Abo laeuft wieder (ohne Einstellung) - kein K22, Marker bleibt leer, das Dokument bleibt abgeschaltet (Schritt 3 braucht die Einstellung)', async () => { legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -2 * TAG).toISOString() }) wartung('active') assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) @@ -933,3 +934,380 @@ test('meldeAboEnde direkt: bezahlt, aber Kunde nicht mehr vorhanden - T19 geht t assert.equal(funktion(id).zahlungMeldung, 'gemeldet') assert.ok(funktion(id).aboEndeMailAt, 'kein Wiederholen') }) + +// ---------------------------------------------------------------- Schritt 3: Rueckkehr des Abos, Wiedereinschalten und K23 (E6) + +const K23_BETREFF = 'Dein Terminplaner läuft wieder – Praxis Muster' +const k23 = () => mails().filter((m) => /^Dein Terminplaner läuft wieder/.test(m.subject)) +const bookingId = (nr) => `b_${funktionsId(nr)}` + +/** Einstellung (termineConfig, Version 7) und eine kuenftige Buchung der Funktion nr: die Daten, die ein Wiedereinschalten behalten muss. */ +function legeDaten(nr = 1) { + aw.seed('termineConfig', { + $id: funktionsId(nr), + customerId: KUNDE, + projectId: projektId(nr), + einstellung: JSON.stringify({ betrieb: { name: 'Salon Müller' }, anrede: 'sie' }), + version: 7, + updatedAt: plus(JETZT, -20 * TAG).toISOString(), + updatedBy: 'silke@example.com', + }) + aw.seed('termineBookings', { + $id: bookingId(nr), + featureId: funktionsId(nr), + customerId: KUNDE, + projectId: projektId(nr), + status: 'bestaetigt', + startsAt: plus(JETZT, 3 * TAG).toISOString(), + endsAt: plus(JETZT, 3 * TAG + 45 * 60_000).toISOString(), + gastName: 'Max Mustermann', + }) +} +const datenStand = (nr = 1) => ({ + einstellung: aw.docs('termineConfig').get(funktionsId(nr)) && { ...aw.docs('termineConfig').get(funktionsId(nr)) }, + buchung: aw.docs('termineBookings').get(bookingId(nr)) && { ...aw.docs('termineBookings').get(bookingId(nr)) }, +}) + +/** Vor 10 Tagen wegen des Abo-Endes abgeschalteter Terminplaner (K22 ging: Marker gesetzt) mit Einstellung und Buchung. */ +function legeAbgeschaltet(nr = 1, over = {}, { daten = true, ...kontext } = {}) { + const id = legeFunktion( + nr, + { + state: 'disabled', + enabled: false, + disabledReason: 'abo_ende', + disabledAt: plus(JETZT, -10 * TAG).toISOString(), + aboEndeMailAt: plus(JETZT, -10 * TAG).toISOString(), + adminNote: 'Abgeschaltet: Wartungs-Abo beendet', + ...over, + }, + kontext, + ) + if (daten) legeDaten(nr) + return id +} + +let wechselNr = 0 +/** Vertragsereignis "wechsel" des Wartungs-Abos sub_w; $createdAt steigt mit jedem Aufruf ("das juengste" ist eindeutig). */ +function wechselEreignis(status) { + wechselNr += 1 + aw.seed('contractEvents', { + $id: `ev_w${wechselNr}`, + $createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, wechselNr)).toISOString(), + customerId: KUNDE, + subscriptionId: 'sub_w', + type: 'wechsel', + productKey: 'wartung', + details: '{}', + status, + }) +} + +test('Rueckkehr innerhalb von 30 Tagen: wieder aktiv, Einstellung und Buchung unveraendert, K23 genau einmal, am Folgetag nichts', async () => { + legeAbgeschaltet(1) + wartung('active') + const daten = datenStand() + assert.ok(daten.einstellung && daten.buchung) + + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) + const doc = funktion() + assert.equal(doc.state, 'active') + assert.equal(doc.enabled, true) + assert.equal(ms(doc.activatedAt), JETZT.getTime()) + assert.equal(doc.disabledAt, null, 'die Loeschfrist endet') + assert.equal(doc.disabledReason, null) + assert.equal(doc.aboEndeMailAt, null) + assert.equal(doc.adminNote, 'Wieder eingeschaltet: Wartungs-Abo läuft wieder') + assert.equal(doc.unlockedByPurchase, true, 'bleibt ein bezahlter Terminplaner') + assert.equal(doc.siteKey, 'abcdefghijklmn01', 'das eingebettete Snippet bleibt gueltig') + assert.deepEqual(datenStand(), daten, 'Einstellung (samt Version) und Buchung sind unveraendert') + assert.equal(aw.docs('termineConfig').get(F1).version, 7) + + assert.equal(k23().length, 1) + assert.equal(mails().length, 1) + const mail = k23()[0] + assert.equal(mail.subject, K23_BETREFF) + assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) + assert.match(mail.text, /Silke Müller/) + assert.match(mail.text, /Dein Wartungs-Abo läuft wieder/) + assert.match(mail.text, /Praxis Muster/) + assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#termine/) + assert.ok(mail.bcc?.length, 'Kopie ans Team wie bei jeder Kundenmail') + + // Folgetag: nichts mehr (das Dokument ist aktiv und das Abo laeuft), keine zweite Mail, nichts geschrieben + const vorher = { ...funktion() } + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) + assert.deepEqual(funktion(), vorher) + assert.equal(mails().length, 1) +}) + +test('K22 ging nie (Marker leer, das Abo kam vorher zurueck): wieder eingeschaltet, weder K22 noch K23', async () => { + legeAbgeschaltet(1, { aboEndeMailAt: null }) + wartung('active') + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, wiederEin: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(funktion().disabledReason, null) + assert.equal(mails().length, 0, 'kein K22, kein K23') +}) + +test('K22 scheiterte am Abschalttag und das Abo kommt am Folgetag zurueck: wieder aktiv, weder K22 noch K23 (der Kunde erfuhr nie vom Ende)', async () => { + legeFunktion(1) + legeDaten(1) + wartung('canceled') + setMailTransportForTests({ sendMail: async () => { throw new Error('Verbindung abgebrochen') } }) + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) + assert.equal(funktion().state, 'disabled') + assert.ok(!funktion().aboEndeMailAt) + + setMailTransportForTests(sink) + wartung('active') + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, wiederEin: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(mails().length, 0) +}) + +test('Kunde ohne E-Mail oder geloescht: wieder eingeschaltet, kein K23, kein Fehler (ein Hinweis im Log, ohne Adresse)', async () => { + for (const vorbereitung of [() => deleteDocument('customers', KUNDE), () => aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' })]) { + for (const id of aw.docs('portalFeatures').keys()) await deleteDocument('portalFeatures', id) + legeAbgeschaltet(1) + wartung('active') + await vorbereitung() + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, wiederEin: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(mails().length, 0) + } + assert.ok(log.warn.mock.calls.length >= 1) +}) + +test('Rueckkehr nach Tag 30 (disabledAt vor 35 Tagen), Einstellung noch da: wieder eingeschaltet; die Loeschung desselben Tagesjobs findet nichts mehr', async () => { + const { loescheAbgeschalteteFunktionen } = await import('../server/services/termine/jobs.js') + legeAbgeschaltet(1, { disabledAt: plus(JETZT, -35 * TAG).toISOString(), aboEndeMailAt: plus(JETZT, -35 * TAG).toISOString() }) + // Gegenprobe: ein Dokument mit Grund team in gleicher Lage wird von der Loeschung erfasst + legeAbgeschaltet(2, { disabledReason: 'team', disabledAt: plus(JETZT, -35 * TAG).toISOString(), aboEndeMailAt: null }) + wartung('active') + const daten = datenStand(1) + + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(funktion().disabledAt, null) + assert.equal(k23().length, 1) + + assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 }, 'nur das team-Dokument') + assert.deepEqual(datenStand(1), daten, 'Einstellung und Buchung des zurueckgekehrten Terminplaners sind noch da') + assert.equal(aw.docs('termineConfig').get(funktionsId(2)), undefined, 'Gegenprobe: team-Dokument geloescht') + assert.equal(funktion().state, 'active') +}) + +test('Nach der Loeschung nie automatisch: ein off-Dokument mit laufendem Abo bleibt unberuehrt und wird nicht einmal gelesen', async () => { + legeFunktion(1, { state: 'off', enabled: false, unlockedByPurchase: false, purchaseStatus: 'zurueckgesetzt', adminNote: 'Daten gelöscht (Löschfrist abgelaufen)' }) + wartung('active') + const vorher = { ...funktion() } + assert.deepEqual(await laufe(), ergebnis()) + assert.deepEqual(funktion(), vorher) + assert.equal(abfragenAufAbos(), 0) + assert.equal(mails().length, 0) +}) + +test('Ohne Einstellung (termineConfig fehlt oder ist unlesbar): bleibt abgeschaltet, kein K23, kein Fehler, nichts geschrieben', async () => { + legeAbgeschaltet(1, {}, { daten: false }) + legeAbgeschaltet(2, {}, { daten: false }) + aw.seed('termineConfig', { $id: funktionsId(2), customerId: KUNDE, projectId: projektId(2), einstellung: '{kaputt', version: 3, updatedAt: JETZT.toISOString(), updatedBy: '' }) + wartung('active') + const vorher = [1, 2].map((nr) => ({ ...funktion(funktionsId(nr)) })) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 2 })) + assert.deepEqual([1, 2].map((nr) => funktion(funktionsId(nr))), vorher) + assert.equal(mails().length, 0) +}) + +test('Ein Kunde, zwei abgeschaltete Terminplaner: nur der mit Einstellung kehrt zurueck, K23 nennt sein Projekt', async () => { + legeAbgeschaltet(1, {}, { projekt: 'Projekt Alpha' }) + legeAbgeschaltet(2, {}, { projekt: 'Projekt Beta', daten: false }) + wartung('active') + assert.deepEqual(await laufe(), ergebnis({ geprueft: 2, wiederEin: 1, mails: 1 })) + assert.equal(funktion(funktionsId(1)).state, 'active') + assert.equal(funktion(funktionsId(2)).state, 'disabled') + assert.deepEqual(mails().map((m) => m.subject), ['Dein Terminplaner läuft wieder – Projekt Alpha']) +}) + +test('Nicht automatisch: team, kunde_geloescht, ohne Grund und abo_ende_vor_einrichtung bleiben trotz laufendem Abo und vorhandener Einstellung unberuehrt', async () => { + const tag = plus(JETZT, -3 * TAG).toISOString() + legeAbgeschaltet(1, { disabledReason: 'team', aboEndeMailAt: null, disabledAt: tag }) + legeAbgeschaltet(2, { disabledReason: 'kunde_geloescht', aboEndeMailAt: null, disabledAt: tag }) + legeAbgeschaltet(3, { disabledReason: undefined, aboEndeMailAt: null, disabledAt: tag }) // Altbestand ohne Grund + legeAufbau(4, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: tag }) // bezahlt, Erstattung laeuft + legeDaten(4) + wartung('active') + const vorher = [1, 2, 3, 4].map((nr) => ({ ...funktion(funktionsId(nr)) })) + const daten = [1, 2, 3, 4].map((nr) => datenStand(nr)) + + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 }), 'nur abo_ende_vor_einrichtung zaehlt als bewertet') + assert.deepEqual([1, 2, 3, 4].map((nr) => funktion(funktionsId(nr))), vorher, 'kein Feld wird angefasst') + assert.deepEqual([1, 2, 3, 4].map((nr) => datenStand(nr)), daten) + assert.equal(mails().length, 0, 'weder K23 noch K22 noch T19') +}) + +// -- Rennen und Lesefehler: im Zweifel bleibt der Terminplaner abgeschaltet + +test('Rennen: das Abo endet zwischen Vorauswahl und Sperre wieder - die frische Pruefung laesst den Terminplaner abgeschaltet, kein K23', async () => { + legeAbgeschaltet(1) + wartung('active') + const vorher = { ...funktion() } + const erg = await mitEinwurf( + liste('subscriptions'), + async () => wartung('canceled'), // die Vorauswahl las "laeuft", dann endet das Abo + () => laufe(), + ) + assert.deepEqual(erg, ergebnis({ geprueft: 1 })) + assert.deepEqual(funktion(), vorher) + assert.equal(mails().length, 0) + assert.equal(abfragenAufAbos(), 2, 'Vorauswahl und frische Pruefung') +}) + +test('Rennen: das Team schaltet zwischen Liste und Sperre selbst wieder ein oder mit Grund team ab - der Abgleich schreibt nichts, kein K23', async () => { + for (const [fall, aenderung, erwartet] of [ + ['Team aktiviert', (doc) => ({ ...doc, state: 'active', enabled: true, disabledAt: null, disabledReason: null, aboEndeMailAt: null }), { state: 'active', disabledReason: null }], + ['Team schaltet neu ab (Grund team)', (doc) => ({ ...doc, disabledReason: 'team', aboEndeMailAt: null }), { state: 'disabled', disabledReason: 'team' }], + ]) { + for (const id of aw.docs('portalFeatures').keys()) await deleteDocument('portalFeatures', id) + legeAbgeschaltet(1) + wartung('active') + let teamStand + const erg = await mitEinwurf( + liste('portalFeatures'), + async () => { + teamStand = aenderung({ ...funktion() }) + aw.seed('portalFeatures', teamStand) + }, + () => laufe(), + ) + assert.deepEqual(erg, ergebnis({ geprueft: 1 }), fall) + assert.deepEqual(funktion(), teamStand, `${fall}: der Abgleich schreibt nichts`) + assert.equal(funktion().state, erwartet.state, fall) + assert.equal(funktion().disabledReason, erwartet.disabledReason, fall) + assert.notEqual(funktion().adminNote, 'Wieder eingeschaltet: Wartungs-Abo läuft wieder', fall) + assert.equal(mails().length, 0, fall) + } +}) + +test('Lesefehler beim Abo (Vorauswahl): bleibt abgeschaltet, fehler 1, kein K23; der naechste Lauf schaltet ein', async () => { + legeAbgeschaltet(1) + wartung('active') + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) + assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) + assert.equal(funktion().state, 'disabled') + assert.equal(mails().length, 0) + + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) + assert.equal(funktion().state, 'active') +}) + +test('Lesefehler erst bei der frischen Pruefung unter der Sperre: bleibt abgeschaltet, fehler 1', async () => { + legeAbgeschaltet(1) + wartung('active') + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, fehler: 1 })) + assert.equal(funktion().state, 'disabled') + assert.equal(funktion().disabledReason, 'abo_ende') + assert.equal(mails().length, 0) +}) + +test('Fehler beim Lesen der Ereignisse (Wechsel-Regel) oder der Einstellung: bleibt abgeschaltet, fehler 1, kein K23', async () => { + legeAbgeschaltet(1) + wartung('active') + aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 }) + assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) + assert.equal(funktion().state, 'disabled') + + aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 }) + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, fehler: 1 })) + assert.equal(funktion().state, 'disabled') + assert.equal(mails().length, 0) + + assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }), 'ohne Fehler schaltet der naechste Lauf ein') +}) + +test('Das Wiedereinschalten scheitert (PATCH): bleibt abgeschaltet, fehler 1 und kein K23 - die Mail folgt nur auf ein geschriebenes active', async () => { + legeAbgeschaltet(1) + wartung('active') + const vorher = { ...funktion() } + aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500 }) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, fehler: 1 })) + assert.deepEqual(funktion(), vorher) + assert.equal(mails().length, 0) + + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) + assert.equal(k23().length, 1) +}) + +test('Sperre: haelt jemand feature-admin:, wartet auch das Wiedereinschalten', async () => { + legeAbgeschaltet(1) + wartung('active') + let fertig = false + let lauf + await withLocks(['feature-admin:p_projekt01'], async () => { + lauf = gleicheAboAb(JETZT).then((erg) => { + fertig = true + return erg + }) + await new Promise((resolve) => setTimeout(resolve, 250)) + assert.equal(fertig, false, 'der Abgleich wartet auf die Sperre') + assert.equal(funktion().state, 'disabled') + assert.equal(mails().length, 0) + }) + assert.deepEqual(await lauf, ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) + assert.equal(funktion().state, 'active') +}) + +test('K23 schlaegt fehl: der Terminplaner bleibt aktiv, fehler 1, wiederEin 1; am Folgetag wird nichts mehr angefasst', async () => { + legeAbgeschaltet(1) + wartung('active') + setMailTransportForTests({ + sendMail: async (mail) => { + if (/läuft wieder/.test(mail.subject)) throw new Error('550 5.1.1 Recipient address rejected') + return sink.sendMail(mail) + }, + }) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, wiederEin: 1, fehler: 1 })) + const doc = funktion() + assert.equal(doc.state, 'active', 'die Reaktivierung bleibt bestehen') + assert.equal(doc.disabledReason, null) + assert.equal(doc.aboEndeMailAt, null) + assert.equal(mails().length, 0) + assert.ok(log.error.mock.calls.length >= 1, 'der Fehler steht im Log (ohne Adresse, das prueft afterEach)') + + setMailTransportForTests(sink) + const vorher = { ...funktion() } + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) + assert.deepEqual(funktion(), vorher) + assert.equal(mails().length, 0) +}) + +// -- Wechsel widerrufen (der Spiegel sagt noch "Wartung"): kein Flattern + +test('Wechsel widerrufen: aktiv => abgeschaltet (K22 hosting), abgeschaltet bleibt abgeschaltet (kein K23); ein neueres, wirksames wechsel-Ereignis hebt das auf => Rueckkehr mit K23', async () => { + legeFunktion(1) + legeDaten(1) + wartung('active') // der Spiegel steht auf Wartung, obwohl der Wechsel widerrufen wurde + wechselEreignis('widerrufen') + + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + assert.equal(funktion().state, 'disabled') + assert.equal(funktion().disabledReason, 'abo_ende') + assert.match(k22()[0].text, /weil Dein Abo ein Hosting-Abo ist/) + + // kein Flattern: am Folgetag und am uebernaechsten bleibt es abgeschaltet, ohne K23 und ohne zweite K22 + const vorher = { ...funktion() } + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) + assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1 })) + assert.deepEqual(funktion(), vorher) + assert.equal(k23().length, 0) + assert.equal(k22().length, 1) + + // ein neueres, wirksames wechsel-Ereignis (erledigt) hebt den Widerruf auf: das Wartungs-Abo laeuft wirklich + wechselEreignis('erledigt') + assert.deepEqual(await laufe(plus(JETZT, 3 * TAG)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(funktion().disabledReason, null) + assert.equal(k23().length, 1) + assert.equal(k22().length, 1) +})