Files
Webklar-Kundenbereich/test/termineAboAbgleich.test.js
T
ClaudeandClaude Sonnet 5.5 17f933dbc7 Abo-Abgleich: aktive Terminplaner ohne Wartungs-Abo abschalten, K22 nachholen
gleicheAboAb(now) liest alle nicht abgeschalteten Terminplaner-Funktionen einmal,
bewertet je Kunde die Abo-Lage (aboMemo) und schaltet aktive, bezahlte Funktionen
ohne laufendes Wartungs-Abo ab (disabledReason abo_ende). Geschrieben wird nur
unter feature-admin:<projectId> nach frischem Lesen von Dokument und Abo; bei
Lesefehlern bleibt alles aktiv. Pilot- und Kulanz-Dokumente zaehlen als
ausgenommen. meldeAboEnde sendet K22 und setzt aboEndeMailAt erst nach Erfolg.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:20:57 +00:00

576 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
// Abo-Abgleich des Terminplaners (server/services/termine/aboAbgleich.js, Terminplaner TP2 E4): aktive Terminplaner ohne laufendes
// Wartungs-Abo abschalten (Schritt 1) und die Mail K22 nachholen (Schritt 2). 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: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
termineConfig: schemaKeys('termineConfig'),
},
defaultLimit: 25,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { subscriptionDoc, hostingDoc, wartungDoc } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { disable } = await import('../server/services/siteFeatures.js')
const { gleicheAboAb, meldeAboEnde } = await import('../server/services/termine/aboAbgleich.js')
after(() => setMailTransportForTests(null))
const TAG = 24 * 60 * 60 * 1000
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin
const KUNDE = 'c_kunde01'
const F1 = 'f_p_projekt01_termine'
const BASIS_VORHER = config.publicBaseUrl
const plus = (von, ms) => new Date(von.getTime() + ms)
const ms = (wert) => new Date(wert).getTime()
let sink
let log
beforeEach(async () => {
for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'termineConfig', 'customers', 'websiteProjects']) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
aw.resetListCalls()
config.publicBaseUrl = 'https://project.webklar.com'
sink = mailSink()
setMailTransportForTests(sink)
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' })
})
afterEach(() => {
// Logs enthalten nie Adresse oder Namen des Kunden (und keine Gaeste: der Abgleich kennt keine)
const text = [...log.error.mock.calls, ...log.warn.mock.calls].map((aufruf) => aufruf.arguments.map(String).join(' ')).join('\n')
mock.timers.reset()
mock.restoreAll()
config.publicBaseUrl = BASIS_VORHER
assert.doesNotMatch(text, /silke@example\.com|Silke|Müller/i, 'keine Adresse und kein Name des Kunden im Log')
})
// ---------------------------------------------------------------- Hilfen
const projektId = (nr) => `p_projekt0${nr}`
const funktionsId = (nr) => `f_p_projekt0${nr}_termine`
/** Aktiver, bezahlter Terminplaner von KUNDE fuer Projekt nr (samt Projekt "Praxis Muster" bzw. eigenem Namen). */
function legeFunktion(nr = 1, over = {}, { projekt = 'Praxis Muster', kunde = KUNDE } = {}) {
aw.seed('websiteProjects', { $id: projektId(nr), customerId: kunde, projectName: projekt })
aw.seed('portalFeatures', {
$id: funktionsId(nr),
customerId: kunde,
projectId: projektId(nr),
featureKey: 'termine',
state: 'active',
enabled: true,
unlockedByPurchase: true,
purchaseStatus: 'paid',
siteKey: `abcdefghijklmn0${nr}`,
...over,
})
return funktionsId(nr)
}
const funktion = (id = F1) => aw.docs('portalFeatures').get(id)
/** Wartungs-Abo von KUNDE (ersetzt nichts: ein einziges Abo-Dokument je Test). */
const wartung = (status, over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_w', { ...wartungDoc, customerId: KUNDE, status, ...over }))
const hosting = (status = 'active', over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...hostingDoc, customerId: KUNDE, status, ...over }))
/** Der Abgleich laeuft "zur Zeit now": die Wanduhr steht fest auf now, damit disable() (new Date()) denselben Zeitpunkt schreibt. */
async function laufe(now = JETZT) {
mock.timers.enable({ apis: ['Date'], now: now.getTime() })
try {
return await gleicheAboAb(now)
} finally {
mock.timers.reset()
}
}
const ERGEBNIS_LEER = { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 }
const ergebnis = (over) => ({ ...ERGEBNIS_LEER, ...over })
const mails = () => sink.sent
const abfragenAufAbos = () => aw.listCalls('subscriptions').length
/**
* Nach der ersten Antwort auf eine Anfrage, auf die passt(urlText, methode) zutrifft, laeuft zuerst dazwischen() zu Ende, dann
* erst bekommt der Aufrufer die (inzwischen veraltete) Antwort: ein anderer Vorgang schiebt sich dazwischen.
*/
async function mitEinwurf(passt, dazwischen, fn) {
const echt = globalThis.fetch
let offen = true
globalThis.fetch = async (url, optionen) => {
const antwort = await echt(url, optionen)
if (offen && passt(decodeURIComponent(String(url)), optionen?.method ?? 'GET')) {
offen = false
globalThis.fetch = echt
await dazwischen()
}
return antwort
}
try {
return await fn()
} finally {
globalThis.fetch = echt
}
}
const liste = (collection) => (url, methode) => methode === 'GET' && new RegExp(`/collections/${collection}/documents(\\?|$)`).test(url)
// ---------------------------------------------------------------- Schritt 1 und 2: Abo beendet
test('Abo beendet: Terminplaner abgeschaltet, K22 an den Kunden, am Folgetag nichts mehr (die Frist wird nie verschoben)', async () => {
legeFunktion(1)
wartung('canceled', { endedAt: JETZT.toISOString(), canceledAt: JETZT.toISOString() })
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
const doc = funktion()
assert.equal(doc.state, 'disabled')
assert.equal(doc.enabled, false)
assert.equal(doc.disabledReason, 'abo_ende')
assert.equal(ms(doc.disabledAt), JETZT.getTime())
assert.equal(doc.adminNote, 'Abgeschaltet: Wartungs-Abo beendet')
assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker erst nach dem Versand, mit now')
assert.equal(mails().length, 1)
const mail = mails()[0]
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com'])
assert.equal(mail.subject, 'Dein Terminplaner wurde beendet – Praxis Muster')
assert.match(mail.text, /Silke Müller/)
assert.match(mail.text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'Block abo_ende, Enddatum = Berliner Tag von disabledAt')
assert.match(mail.text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum = disabledAt + 30 Tage')
assert.match(mail.text, /Ab 04\.11\.2026 löschen wir/)
assert.match(mail.text, /149 €/, 'Preis aus dem Register (priceLabel)')
assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#termine/)
assert.doesNotMatch(mail.text, /Hosting-Abo/, 'nicht der Hosting-Block')
assert.ok(mail.bcc?.length, 'Kopie ans Team wie bei jeder Kundenmail')
// Folgetag: nichts mehr, disabledAt und Marker bleiben, keine zweite Mail
const vorher = { ...funktion() }
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
assert.deepEqual(funktion(), vorher)
assert.equal(mails().length, 1)
})
test('Kein Abo-Dokument: abgeschaltet, K22 mit dem Abo-Ende-Block', async () => {
legeFunktion(1)
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(funktion().state, 'disabled')
assert.equal(funktion().disabledReason, 'abo_ende')
assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/)
})
test('Nur ein Hosting-Abo aktiv: abgeschaltet, K22 mit dem Block hosting ("Hosting-Abo")', async () => {
legeFunktion(1)
hosting('active')
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(funktion().state, 'disabled')
const text = mails()[0].text
assert.match(text, /weil Dein Abo ein Hosting-Abo ist/)
assert.doesNotMatch(text, /Wartungs-Abo am/, 'nicht der Block abo_ende')
assert.match(text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'der Kasten "Was jetzt gilt" gehoert auch zum Hosting-Fall')
})
test('Widerrufener Wechsel ins Wartungs-Abo (Spiegel sagt "Wartung"): abgeschaltet, K22 mit dem Block hosting', async () => {
legeFunktion(1)
wartung('active')
aw.seed('contractEvents', { $id: 'ev_1', customerId: KUNDE, subscriptionId: 'sub_w', type: 'wechsel', productKey: 'wartung', details: '{}', status: 'widerrufen' })
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(funktion().state, 'disabled')
assert.match(mails()[0].text, /weil Dein Abo ein Hosting-Abo ist/)
})
// ---------------------------------------------------------------- Laeuft weiter / endet: die Status-Tabelle
const LAEUFT_WEITER = [
['past_due', {}],
['trialing', {}],
['active', { cancelAtPeriodEnd: true, cancelAt: new Date(Date.now() + 10 * TAG).toISOString() }],
]
for (const [status, zusatz] of LAEUFT_WEITER) {
test(`Abo ${status}${zusatz.cancelAtPeriodEnd ? ' mit vorgemerkter Kuendigung in 10 Tagen' : ''}: laeuft weiter, Terminplaner bleibt aktiv, keine Mail`, async () => {
legeFunktion(1)
wartung(status, zusatz)
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(funktion().enabled, true)
assert.equal(funktion().disabledAt, undefined)
assert.equal(mails().length, 0)
})
}
for (const status of ['unpaid', 'incomplete_expired', 'canceled']) {
test(`Abo ${status}: Terminplaner abgeschaltet`, async () => {
legeFunktion(1)
wartung(status)
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(funktion().state, 'disabled')
assert.equal(funktion().disabledReason, 'abo_ende')
})
}
// ---------------------------------------------------------------- Lesefehler: nie abschalten
test('Lesefehler beim Abo: der Terminplaner bleibt aktiv, fehler 1, keine Mail; der naechste Lauf schaltet ab', async () => {
legeFunktion(1)
wartung('canceled')
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
assert.deepEqual(await laufe(), ergebnis({ fehler: 1 }), 'nicht lesbar heisst nicht bewertet: geprueft 0')
assert.equal(funktion().state, 'active')
assert.equal(mails().length, 0)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(funktion().state, 'disabled')
})
test('Lesefehler erst bei der frischen Pruefung unter der Sperre: bleibt aktiv; der folgende Lauf schaltet ab', async () => {
legeFunktion(1)
wartung('canceled')
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 })
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, fehler: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(funktion().disabledAt, undefined)
assert.equal(mails().length, 0)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(funktion().state, 'disabled')
})
test('Fehler beim Lesen der Ereignisse (Wechsel-Regel): bleibt aktiv, fehler 1', async () => {
legeFunktion(1)
wartung('active')
aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 })
assert.deepEqual(await laufe(), ergebnis({ fehler: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(mails().length, 0)
})
test('Fehler beim Lesen der Dokumentliste: kein Wurf, Ergebnis mit fehler, nichts geaendert', async () => {
legeFunktion(1)
wartung('canceled')
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500 })
assert.deepEqual(await gleicheAboAb(JETZT), ergebnis({ fehler: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(abfragenAufAbos(), 0, 'ohne Liste wird kein Abo gelesen')
assert.equal(mails().length, 0)
})
test('Ein fehlgeschlagenes Abschalten stoppt den Lauf nicht: das Dokument bleibt aktiv, die anderen Kunden kommen dran', async () => {
aw.seed('customers', { $id: 'c_kunde02', name: 'Hans Zweit', email: 'hans@example.com' })
legeFunktion(1)
legeFunktion(2, {}, { projekt: 'Zweites Projekt', kunde: 'c_kunde02' })
aw.seed('subscriptions', subscriptionDoc('sub_w2', { ...wartungDoc, customerId: 'c_kunde02', status: 'canceled' }))
wartung('canceled')
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500 })
const erg = await laufe()
assert.deepEqual(erg, ergebnis({ geprueft: 2, abgeschaltet: 1, mails: 1, fehler: 1 }))
assert.deepEqual([funktion(funktionsId(1)).state, funktion(funktionsId(2)).state].sort(), ['active', 'disabled'])
assert.equal(mails().length, 1)
// der naechste Lauf holt das Fehlgeschlagene nach
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 2, abgeschaltet: 1, mails: 1 }))
assert.equal(funktion(funktionsId(1)).state, 'disabled')
assert.equal(funktion(funktionsId(2)).state, 'disabled')
})
// ---------------------------------------------------------------- Pilot/Kulanz, Entwuerfe, andere Funktionen
test('Pilot und Kulanz (unlockedByPurchase false oder fehlend): unveraendert aktiv, nicht geprueft, als ausgenommen gezaehlt, ohne Abo-Abfrage und Mail', async () => {
aw.seed('customers', { $id: 'c_kunde02', name: 'Hans Zweit', email: 'hans@example.com' })
legeFunktion(1, { unlockedByPurchase: false })
const ohneFeld = legeFunktion(2, { unlockedByPurchase: undefined }, { kunde: 'c_kunde02' }) // das Feld fehlt (Altbestand, Staff-Freischaltung)
assert.notEqual(funktion(ohneFeld).unlockedByPurchase, true)
assert.deepEqual(await laufe(), ergebnis({ ausgenommen: 2 }))
assert.equal(funktion(funktionsId(1)).state, 'active')
assert.equal(funktion(ohneFeld).state, 'active')
assert.equal(abfragenAufAbos(), 0, 'fuer ausgenommene Dokumente wird das Abo nicht gelesen')
assert.equal(mails().length, 0)
})
test('Entwuerfe (off) werden nicht gelesen: eine Abfrage mit featureKey termine und notEqual state off; andere Funktionen bleiben unberuehrt', async () => {
legeFunktion(1, { state: 'off', enabled: false })
aw.seed('portalFeatures', { $id: 'f_p_projekt09_news', customerId: KUNDE, projectId: 'p_projekt09', featureKey: 'news', state: 'active', enabled: true, unlockedByPurchase: true, purchaseStatus: 'paid' })
assert.deepEqual(await laufe(), ergebnis())
assert.equal(funktion().state, 'off')
assert.equal(funktion('f_p_projekt09_news').state, 'active', 'News fasst der Abgleich nie an')
const listen = aw.listCalls('portalFeatures')
assert.equal(listen.length, 1, 'eine einzige Listenabfrage')
const abfragen = listen[0].queries
assert.ok(abfragen.some((q) => q.method === 'equal' && q.attribute === 'featureKey' && q.values[0] === 'termine'))
assert.ok(abfragen.some((q) => q.method === 'notEqual' && q.attribute === 'state' && q.values[0] === 'off'), JSON.stringify(abfragen))
assert.equal(abfragenAufAbos(), 0)
})
test('ordered, setup und abo_ende_vor_einrichtung zaehlen als geprueft, E4 aendert daran nichts (E5 erweitert)', async () => {
legeFunktion(1, { state: 'ordered', enabled: false })
legeFunktion(2, { state: 'setup', enabled: false })
legeFunktion(3, { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -TAG).toISOString() })
const vorher = [1, 2, 3].map((nr) => ({ ...funktion(funktionsId(nr)) }))
assert.deepEqual(await laufe(), ergebnis({ geprueft: 3 }))
assert.deepEqual([1, 2, 3].map((nr) => funktion(funktionsId(nr))), vorher)
assert.equal(mails().length, 0)
})
test('Alt-disabled ohne Grund, team und kunde_geloescht: nie angefasst, nie K22', async () => {
const tag = plus(JETZT, -3 * TAG).toISOString()
legeFunktion(1, { state: 'disabled', enabled: false, disabledAt: tag }) // Altbestand ohne Grund
legeFunktion(2, { state: 'disabled', enabled: false, disabledAt: tag, disabledReason: 'team' })
legeFunktion(3, { state: 'disabled', enabled: false, disabledAt: tag, disabledReason: 'kunde_geloescht' })
const vorher = [1, 2, 3].map((nr) => ({ ...funktion(funktionsId(nr)) }))
assert.deepEqual(await laufe(), ergebnis())
assert.deepEqual([1, 2, 3].map((nr) => funktion(funktionsId(nr))), vorher)
assert.equal(mails().length, 0)
assert.equal(abfragenAufAbos(), 0)
})
test('Dokument ohne Kunden-ID wird nie als "kein Abo" gedeutet: bleibt aktiv, fehler 1', async () => {
legeFunktion(1, { customerId: '' })
assert.deepEqual(await laufe(), ergebnis({ fehler: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(abfragenAufAbos(), 0)
})
// ---------------------------------------------------------------- Rennen
test('Rennen: zwischen Vorauswahl und Sperre kommt ein aktives Abo - die frische Pruefung laesst den Terminplaner aktiv', async () => {
legeFunktion(1)
wartung('canceled')
const erg = await mitEinwurf(
liste('subscriptions'),
async () => wartung('active'), // das Abo kommt zurueck, nachdem die Vorauswahl "beendet" gelesen hat
() => laufe(),
)
assert.deepEqual(erg, ergebnis({ geprueft: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(mails().length, 0)
assert.equal(abfragenAufAbos(), 2, 'Vorauswahl und frische Pruefung')
})
test('Rennen: zwischen Liste und Sperre schaltet das Team ab - Grund team und disabledAt bleiben, kein K22', async () => {
legeFunktion(1)
wartung('canceled')
await mitEinwurf(
liste('portalFeatures'),
() => disable(F1, { reason: 'team', adminNote: 'Abgeschaltet von Kenso' }),
() => gleicheAboAb(JETZT),
).then((erg) => assert.deepEqual(erg, ergebnis({ geprueft: 1 })))
const doc = funktion()
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'team')
assert.equal(doc.adminNote, 'Abgeschaltet von Kenso')
assert.ok(Math.abs(ms(doc.disabledAt) - Date.now()) < 60_000, 'disabledAt vom Team, nicht vom Abgleich')
assert.equal(doc.aboEndeMailAt, undefined)
assert.equal(mails().length, 0)
const vorher = { ...doc }
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis(), 'auch am Folgetag nichts')
assert.deepEqual(funktion(), vorher)
})
test('Rennen: zwischen Liste und Sperre wird das Dokument zum Kulanz-Dokument (nicht vom Kauf freigeschaltet) - bleibt aktiv, nichts passiert', async () => {
legeFunktion(1)
wartung('canceled')
const erg = await mitEinwurf(
liste('portalFeatures'),
async () => aw.seed('portalFeatures', { ...funktion(), unlockedByPurchase: false }),
() => laufe(),
)
assert.deepEqual(erg, ergebnis({ geprueft: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(funktion().disabledAt, undefined)
assert.equal(mails().length, 0)
})
test('Sperre: haelt jemand feature-admin:<projectId>, bleibt der Abgleich offen, bis sie frei ist', async () => {
legeFunktion(1)
wartung('canceled')
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, 'active')
assert.equal(mails().length, 0)
})
assert.deepEqual(await lauf, ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(funktion().state, 'disabled')
})
test('Sperre: ein anderes Projekt blockiert nicht', async () => {
legeFunktion(1)
wartung('canceled')
const erg = await withLocks(['feature-admin:p_fremd'], () => gleicheAboAb(JETZT))
assert.deepEqual(erg, ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
})
// ---------------------------------------------------------------- Memo
test('Memo: ein Kunde mit drei Projekten - die Vorauswahl liest das Abo einmal, drei Dokumente abgeschaltet, je Projekt eine K22', async () => {
legeFunktion(1, {}, { projekt: 'Projekt Alpha' })
legeFunktion(2, {}, { projekt: 'Projekt Beta' })
legeFunktion(3, {}, { projekt: 'Projekt Gamma' })
wartung('canceled')
assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, abgeschaltet: 3, mails: 3 }))
for (const nr of [1, 2, 3]) {
assert.equal(funktion(funktionsId(nr)).state, 'disabled')
assert.equal(funktion(funktionsId(nr)).disabledReason, 'abo_ende')
}
assert.deepEqual(mails().map((m) => m.subject).sort(), [
'Dein Terminplaner wurde beendet – Projekt Alpha',
'Dein Terminplaner wurde beendet – Projekt Beta',
'Dein Terminplaner wurde beendet – Projekt Gamma',
])
// eine Abfrage fuer die Vorauswahl (nicht eine je Projekt) plus je abzuschaltendem Dokument die frische Pruefung unter seiner Sperre
assert.equal(abfragenAufAbos(), 1 + 3)
})
test('Memo: mehrere Kunden - je Kunde eine Vorauswahl, die Ausnahmen kosten keine Abfrage', async () => {
aw.seed('customers', { $id: 'c_kunde02', name: 'Hans Zweit', email: 'hans@example.com' })
legeFunktion(1)
legeFunktion(2, {}, { kunde: 'c_kunde02' })
legeFunktion(3, { unlockedByPurchase: false }, { kunde: 'c_kunde03' })
wartung('active')
aw.seed('subscriptions', subscriptionDoc('sub_w2', { ...wartungDoc, customerId: 'c_kunde02', status: 'active' }))
assert.deepEqual(await laufe(), ergebnis({ geprueft: 2, ausgenommen: 1 }))
assert.equal(abfragenAufAbos(), 2)
})
// ---------------------------------------------------------------- K22-Nachlauf
test('K22-Nachlauf: der Transport wirft - disabled, Marker leer, fehler 1; der naechste Tag sendet K22 mit unveraenderten Daten', async () => {
legeFunktion(1)
wartung('canceled')
setMailTransportForTests({
sendMail: async () => {
throw new Error('550 5.1.1 <silke@example.com> Recipient address rejected')
},
})
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
const doc = funktion()
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'abo_ende')
assert.ok(!doc.aboEndeMailAt, 'Marker erst nach Erfolg')
assert.ok(log.error.mock.calls.length >= 1, 'der Fehler steht im Log (ohne Adresse, das prueft afterEach)')
setMailTransportForTests(sink)
const disabledAt = doc.disabledAt
const naechster = plus(JETZT, TAG)
assert.deepEqual(await laufe(naechster), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(mails().length, 1)
assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'Enddatum aus disabledAt, nicht vom Folgetag')
assert.match(mails()[0].text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum bleibt: die Frist wuchs nicht')
assert.equal(funktion().disabledAt, disabledAt)
assert.equal(ms(funktion().aboEndeMailAt), naechster.getTime())
assert.deepEqual(await laufe(plus(naechster, TAG)), ergebnis({ geprueft: 1 }), 'am dritten Tag nichts mehr')
assert.equal(mails().length, 1)
})
test('K22-Nachlauf: Marker-Schreiben scheitert nach dem Versand - fehler 1, die Mail ist raus, der naechste Tag meldet erneut und setzt den Marker', async () => {
legeFunktion(1)
wartung('canceled')
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, after: 1 }) // 1. PATCH = abschalten, 2. = Marker
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1, fehler: 1 }))
assert.equal(funktion().state, 'disabled')
assert.ok(!funktion().aboEndeMailAt)
assert.equal(mails().length, 1)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }), 'lieber doppelt als gar nicht')
assert.equal(ms(funktion().aboEndeMailAt), plus(JETZT, TAG).getTime())
assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1 }))
})
for (const [fall, vorbereitung] of [
['Kunde geloescht', () => deleteDocument('customers', KUNDE)],
['Kunde ohne E-Mail', () => aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' })],
]) {
test(`K22-Nachlauf: ${fall} - kein Wurf, keine Mail, Marker gesetzt, am Folgetag nichts`, async () => {
legeFunktion(1)
wartung('canceled')
await vorbereitung()
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1 }))
assert.equal(funktion().state, 'disabled')
assert.equal(ms(funktion().aboEndeMailAt), JETZT.getTime(), 'kein Wiederholen')
assert.equal(mails().length, 0)
assert.ok(log.warn.mock.calls.length >= 1, 'ein Hinweis im Log, ohne Adresse (das prueft afterEach)')
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 0)
})
}
test('K22-Nachlauf: das Abo laeuft wieder - Schritt 2 uebergeben (E6), keine Mail, Marker bleibt leer', 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 }))
assert.equal(funktion().state, 'disabled')
assert.ok(!funktion().aboEndeMailAt)
assert.equal(mails().length, 0)
})
test('K22-Nachlauf: mit Marker (aboEndeMailAt) nie eine zweite Mail', async () => {
legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -2 * TAG).toISOString(), aboEndeMailAt: plus(JETZT, -2 * TAG).toISOString() })
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 0)
})
test('K22-Nachlauf: Fehler beim frischen Lesen des Abos - keine Mail, fehler 1, der naechste Tag holt sie nach', async () => {
legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -TAG).toISOString() })
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) // Vorauswahl ok, frische Pruefung scheitert
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, fehler: 1 }))
assert.equal(mails().length, 0)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }))
assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 04\.10\.2026 beendet wurde/, 'Enddatum = Tag von disabledAt (gestern)')
assert.match(mails()[0].text, /Bis 03\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum = disabledAt (04.10.) + 30 Tage')
})
// ---------------------------------------------------------------- meldeAboEnde (E5/E9 rufen nur ihn)
test('meldeAboEnde: sendet K22 und setzt den Marker (true), mit Marker nichts mehr (true, keine Mail), Fehler im Versand false', async () => {
const id = legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() })
const lage = { laeuft: false, art: 'beendet', endedAt: null }
const spaeter = plus(JETZT, 2 * TAG)
setMailTransportForTests({ sendMail: async () => { throw new Error('Verbindung abgebrochen') } })
assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter }), false)
assert.ok(!funktion(id).aboEndeMailAt)
setMailTransportForTests(sink)
let gesendet = 0
assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter, onGesendet: () => (gesendet += 1) }), true)
assert.equal(gesendet, 1)
assert.equal(mails().length, 1)
assert.equal(ms(funktion(id).aboEndeMailAt), spaeter.getTime())
assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter, onGesendet: () => (gesendet += 1) }), true)
assert.equal(gesendet, 1)
assert.equal(mails().length, 1, 'der Marker verhindert die zweite Mail')
})
test('meldeAboEnde: unbekannter Projektname faellt auf "Dein Projekt" zurueck, ein Lesefehler beim Projekt wiederholt am naechsten Tag', async () => {
const id = legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() })
await deleteDocument('websiteProjects', 'p_projekt01')
const lage = { laeuft: false, art: 'beendet', endedAt: null }
assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), true)
assert.equal(mails()[0].subject, 'Dein Terminplaner wurde beendet – Dein Projekt')
const id2 = legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() })
aw.failNext({ collection: 'websiteProjects', method: 'GET', status: 500 })
assert.equal(await meldeAboEnde(funktion(id2), { lage, now: JETZT }), false)
assert.ok(!funktion(id2).aboEndeMailAt)
assert.equal(mails().length, 1)
})