Files
Webklar-Kundenbereich/test/termineAboAbgleich.test.js
T
ClaudeandClaude Sonnet 5.5 d66dd60a76 Zahlung nach Abo-Ende: Zusatztests Webhook und Abgleich (T19 nie doppelt)
Nur Tests in test/termineAboAbgleich.test.js, kein Produktionscode: handleFeatureCheckoutEvent
und gleicheAboAb gegeneinander (Review Focus 1, nie Geld ohne Spur, nie doppelt).
- Zahlung nach dem Abschalten: genau ein T19 (zahlung_nach_ende, Sitzung), kein Ticket,
  kein K15/T16; Wiederholung und async_payment_succeeded stumm
- T19-Versand scheitert: Webhook wirft, Zahlung gebucht, Fall offen; erste Wiederholung
  mit funktionierendem Versand sendet genau ein T19
- Folgetag-Abgleich: K22 vor_einrichtung, Marker aboEndeMailAt, kein zweites T19
- Abgleich vor der Webhook-Wiederholung und parallel (Promise.all): insgesamt ein T19
- Grund team: T16 abgeschaltet, kein T19; Abo laeuft (ordered/setup): Ticket, K15, T16
- Rennen Zahlung vor Abgleich: Ticket, T16 und ein T19; Stripe-Retry stumm

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

2230 lines
110 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/E5/E6; E10: Zusammenspiel mit dem Zahlungs-Webhook, letzter Abschnitt): 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: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
termineConfig: schemaKeys('termineConfig'),
termineBookings: schemaKeys('termineBookings'),
},
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, activateClosedTickets, handleFeatureCheckoutEvent } = await import('../server/services/siteFeatures.js')
const { gleicheAboAb, meldeAboEnde, endeTermineFuerKunde } = 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', 'termineBookings', 'customers', 'websiteProjects', 'workorders']) {
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)
const T19_BETREFF = '[Abo] Erstattung fällig: Terminplaner-Einrichtung'
const K22_BETREFF = /^Dein Terminplaner wurde beendet/
/** Endzustand der Meldung zu einer bezahlten, abgeschalteten Einrichtung (E5): T19 gemeldet, K22 gesendet (Marker). Der Abgleich laesst sie in Ruhe. */
const ERSTATTET = { zahlungMeldung: 'gemeldet', aboEndeMailAt: '2026-10-04T08:00:00.000Z' }
/** Bezahlte Einrichtung im Aufbau (setup): Sitzung cs_test_1, Rechnungslink, offenes Ticket WO-4711 (Dokument t_<nr> in workorders). */
function legeAufbau(nr = 1, over = {}) {
aw.seed('workorders', { $id: `t_${nr}`, woid: 'WO-4711', status: 'Open', topic: 'Terminplaner einbauen – Praxis Muster' })
return legeFunktion(nr, {
state: 'setup',
enabled: false,
stripeSessionId: 'cs_test_1',
stripeInvoiceId: 'in_1',
invoiceUrl: 'https://invoice.stripe.test/in_1',
ticketId: `t_${nr}`,
...over,
})
}
/** Bestellung ohne Zahlung (ordered): die Stripe-Sitzung ist offen, unlockedByPurchase fehlt. */
const legeBestellung = (nr = 1, over = {}) =>
legeFunktion(nr, { state: 'ordered', enabled: false, unlockedByPurchase: false, purchaseStatus: 'ordered', stripeSessionId: 'cs_test_1', ...over })
/** 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 t19 = () => mails().filter((m) => m.subject === T19_BETREFF)
const k22 = () => mails().filter((m) => K22_BETREFF.test(m.subject))
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('Laeuft das Abo: ordered, setup und ein abgeschaltetes abo_ende_vor_einrichtung zaehlen als geprueft und bleiben unberuehrt (kein T19, kein K22)', async () => {
wartung('active')
legeBestellung(1)
legeAufbau(2)
legeFunktion(3, { ...ERSTATTET, 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 (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 }))
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)
})
// ---------------------------------------------------------------- Schritt 1b: Abo endet vor der Einrichtung (E5)
test('Bezahlt im Aufbau (setup, Ticket) ohne Abo: abgeschaltet, ein T19 an das Team, K22 vor_einrichtung, Ticket bleibt offen, am Folgetag nichts', async () => {
legeAufbau(1)
const ticketVorher = { ...aw.docs('workorders').get('t_1') }
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_vor_einrichtung')
assert.equal(ms(doc.disabledAt), JETZT.getTime())
assert.equal(doc.adminNote, 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung')
assert.equal(doc.purchaseStatus, 'paid', 'die Zahlung bleibt gebucht')
assert.equal(doc.zahlungMeldung, 'gemeldet', 'T19 ist raus')
assert.equal(doc.stripeSessionId, 'cs_test_1')
assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker erst nach T19 und K22')
// T19: genau einmal, an das Team, mit Kunde, Projekt, Sitzung, Rechnungslink und Ticketnummer
assert.equal(t19().length, 1)
const mailT19 = t19()[0]
assert.deepEqual(mailT19.to.map((a) => a.address).sort(), [...config.chatNotify.emails].sort())
assert.match(mailT19.text, /Silke Müller/)
assert.match(mailT19.text, /Praxis Muster/)
assert.match(mailT19.text, /cs_test_1/)
assert.ok(mailT19.html.includes('https://invoice.stripe.test/in_1'), 'Rechnungslink')
assert.match(mailT19.text, /WO-4711/)
assert.match(mailT19.text, /Ticket noch offen/)
assert.doesNotMatch(mailT19.text, /Zahlung nach dem Abo-Ende/, 'die Zahlung kam vor dem Abo-Ende')
// K22 an den Kunden: Block vor_einrichtung mit Preis, ohne den Kasten "Was jetzt gilt"
assert.equal(k22().length, 1)
const mailK22 = k22()[0]
assert.deepEqual(mailK22.to.map((a) => a.address), ['silke@example.com'])
assert.equal(mailK22.subject, 'Dein Terminplaner wurde beendet – Praxis Muster')
assert.match(mailK22.text, /Dein Wartungs-Abo wurde am 05\.10\.2026 beendet, noch vor der Einrichtung Deines Terminplaners/)
assert.match(mailK22.text, /erstatten Dir die Einrichtung \(149 €\) vollständig/)
assert.doesNotMatch(mailK22.text, /Was jetzt gilt|löschen wir|Hosting-Abo|weil Dein Wartungs-Abo/, 'kein Loeschkasten, nicht die anderen Gruende')
assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false], 'zuerst T19, dann K22')
// das Ticket wird nicht angefasst
assert.deepEqual(aw.docs('workorders').get('t_1'), ticketVorher)
assert.equal(aw.docs('workorders').get('t_1').status, 'Open')
// zweiter Lauf: weder T19 noch K22, nichts geaendert
const vorher = { ...funktion() }
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
assert.deepEqual(funktion(), vorher)
assert.equal(t19().length, 1)
assert.equal(k22().length, 1)
})
test('T19 schlaegt fehl: abgeschaltet, kein Marker, kein K22, fehler; der naechste Lauf sendet T19 und danach K22, spaetere Stripe-Ereignisse kein zweites T19', async () => {
legeAufbau(1)
setMailTransportForTests({
sendMail: async (mail) => {
if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen')
return sink.sendMail(mail)
},
})
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
let doc = funktion()
assert.equal(doc.state, 'disabled', 'abgeschaltet wird trotzdem')
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
assert.ok(!doc.zahlungMeldung, 'Marker gemeldet erst nach gesendetem T19')
assert.ok(!doc.aboEndeMailAt, 'Marker aboEndeMailAt erst nach T19 und K22')
assert.equal(doc.purchaseStatus, 'paid')
assert.equal(mails().length, 0, 'auch K22 ging nicht (T19 zuerst)')
const disabledAt = doc.disabledAt
setMailTransportForTests(sink)
const naechster = plus(JETZT, TAG)
assert.deepEqual(await laufe(naechster), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(t19().length, 1)
assert.equal(k22().length, 1)
assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false], 'T19 vor K22')
assert.match(k22()[0].text, /am 05\.10\.2026 beendet/, 'Enddatum aus disabledAt, nicht vom Folgetag')
doc = funktion()
assert.equal(doc.zahlungMeldung, 'gemeldet')
assert.equal(doc.purchaseStatus, 'paid')
assert.equal(ms(doc.aboEndeMailAt), naechster.getTime())
assert.equal(doc.disabledAt, disabledAt, 'die Frist wuchs nicht')
assert.deepEqual(await laufe(plus(naechster, TAG)), ergebnis({ geprueft: 1 }), 'danach nie wieder')
assert.equal(mails().length, 2)
// ein spaeteres Stripe-Ereignis zur selben Sitzung bucht nichts und meldet nichts mehr
await handleFeatureCheckoutEvent({
type: 'checkout.session.async_payment_succeeded',
data: { object: { id: 'cs_test_1', payment_status: 'paid', metadata: { customerId: KUNDE, projectId: 'p_projekt01', featureKey: 'termine' } } },
})
assert.equal(t19().length, 1, 'kein zweites T19')
assert.equal(mails().length, 2)
assert.equal(funktion().state, 'disabled')
})
test('T19 schlaegt fehl und K22 wuerde gehen: K22 wird nicht vorgezogen (Reihenfolge T19, K22, Marker), fehler zaehlt je Lauf', async () => {
legeAufbau(1)
setMailTransportForTests({
sendMail: async (mail) => {
if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen')
return sink.sendMail(mail)
},
})
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, fehler: 1 }), 'auch der zweite Tag scheitert an T19')
assert.equal(k22().length, 0)
assert.ok(!funktion().aboEndeMailAt)
assert.ok(!funktion().zahlungMeldung)
})
test('Unbezahltes ordered ohne Abo: abgeschaltet, kein T19, kein K22, kein Marker, Sitzung unveraendert, auch am Folgetag nichts', async () => {
legeBestellung(1)
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1 }))
const doc = funktion()
assert.equal(doc.state, 'disabled')
assert.equal(doc.enabled, false)
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
assert.equal(doc.adminNote, 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung')
assert.ok(!doc.aboEndeMailAt, 'kein Marker')
assert.ok(!doc.zahlungMeldung)
assert.equal(doc.stripeSessionId, 'cs_test_1', 'die offene Stripe-Sitzung wird nicht angefasst')
assert.equal(doc.purchaseStatus, 'ordered')
assert.equal(doc.unlockedByPurchase, false)
assert.equal(mails().length, 0)
const vorher = { ...funktion() }
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
assert.deepEqual(funktion(), vorher, 'kein Schreibzugriff')
assert.equal(mails().length, 0)
})
test('Unbezahltes ordered, danach trifft die Zahlung ein: das Webhook-Ereignis meldet T19 genau einmal (nach dem Abo-Ende), der naechste Lauf sendet K22 vor_einrichtung', async () => {
legeBestellung(1)
await laufe(JETZT)
assert.equal(mails().length, 0)
await handleFeatureCheckoutEvent({
type: 'checkout.session.completed',
data: { object: { id: 'cs_test_1', payment_status: 'paid', metadata: { customerId: KUNDE, projectId: 'p_projekt01', featureKey: 'termine' } } },
})
assert.equal(t19().length, 1)
assert.match(t19()[0].text, /Zahlung nach dem Abo-Ende/)
assert.equal(funktion().state, 'disabled')
assert.equal(funktion().unlockedByPurchase, true)
assert.equal(funktion().zahlungMeldung, 'gemeldet')
assert.equal(k22().length, 0, 'K22 gehoert dem Abgleich')
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(t19().length, 1, 'kein zweites T19')
assert.equal(k22().length, 1)
assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/)
assert.ok(funktion().aboEndeMailAt)
assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 2)
})
test('setup ohne ticketId ("Ticket fehlt"): T19 ohne den Block ticket_offen, K22 vor_einrichtung', async () => {
legeAufbau(1, { ticketId: '' })
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(t19().length, 1)
assert.doesNotMatch(t19()[0].text, /Ticket noch offen/)
assert.doesNotMatch(t19()[0].text, /WO-4711/)
assert.equal(k22().length, 1)
assert.equal(funktion().zahlungMeldung, 'gemeldet')
})
for (const status of ['past_due', 'trialing', 'active']) {
test(`Abo ${status}: ordered und setup bleiben unberuehrt, keine Mail`, async () => {
legeBestellung(1)
legeAufbau(2)
wartung(status)
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('Lesefehler beim Abo: ordered und setup bleiben unberuehrt, fehler 1; der naechste Lauf schaltet ab (auch beim Fehler der frischen Pruefung)', async () => {
legeBestellung(1)
legeAufbau(2)
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
assert.deepEqual(await laufe(), ergebnis({ fehler: 1 }))
assert.deepEqual([funktion(funktionsId(1)).state, funktion(funktionsId(2)).state], ['ordered', 'setup'])
assert.equal(mails().length, 0)
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) // Vorauswahl ok, die frische Pruefung des ersten Dokuments scheitert
const zweiter = await laufe(plus(JETZT, TAG))
assert.equal(zweiter.fehler, 1)
assert.equal(zweiter.abgeschaltet, 1, 'das andere Dokument kommt dran')
const dritter = await laufe(plus(JETZT, 2 * TAG))
assert.equal(dritter.fehler, 0)
assert.deepEqual([funktion(funktionsId(1)).state, funktion(funktionsId(2)).state], ['disabled', 'disabled'])
assert.equal(t19().length, 1, 'T19 nur fuer die bezahlte Einrichtung')
assert.equal(k22().length, 1)
})
test('Rennen: das Abo kommt zwischen Vorauswahl und Sperre zurueck - ordered und setup bleiben, nichts wird gesendet', async () => {
legeBestellung(1)
legeAufbau(2)
const erg = await mitEinwurf(liste('subscriptions'), async () => wartung('active'), () => laufe())
assert.deepEqual(erg, ergebnis({ geprueft: 2 }))
assert.deepEqual([funktion(funktionsId(1)).state, funktion(funktionsId(2)).state], ['ordered', 'setup'])
assert.equal(mails().length, 0)
})
test('Rennen: das Team aktiviert zwischen Liste und Sperre - das frisch gelesene Dokument ist active, Schritt 1b greift nicht', async () => {
legeAufbau(1)
const erg = await mitEinwurf(
liste('portalFeatures'),
async () => aw.seed('portalFeatures', { ...funktion(), state: 'active', enabled: true }),
() => laufe(),
)
assert.deepEqual(erg, ergebnis({ geprueft: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(funktion().disabledReason, undefined)
assert.equal(mails().length, 0)
})
test('Review Focus 1, nie ohne Spur: das Ticket wird nach dem Abo-Ende geschlossen - activateClosedTickets aktiviert das abgeschaltete Dokument nicht', async () => {
legeAufbau(1)
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(t19().length, 1, 'die Spur: T19 mit dem offenen Ticket')
aw.seed('workorders', { ...aw.docs('workorders').get('t_1'), status: 'Closed' })
assert.deepEqual(await activateClosedTickets({ now: plus(JETZT, TAG) }), [])
const doc = funktion()
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
assert.equal(doc.enabled, false)
assert.equal(mails().length, 2, 'keine weitere Mail')
})
test('abo_ende_vor_einrichtung-Dokument mit Abo-Rueckkehr: bleibt wie es ist (die Rueckkehr gilt nur fuer abo_ende)', async () => {
legeFunktion(1, { ...ERSTATTET, state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -TAG).toISOString() })
wartung('active')
const vorher = { ...funktion() }
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 }))
assert.deepEqual(funktion(), vorher)
assert.equal(mails().length, 0)
})
test('Unbezahltes, abgeschaltetes abo_ende_vor_einrichtung-Dokument wird taeglich uebergangen: kein Schreibzugriff, keine Mail', async () => {
legeBestellung(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -3 * TAG).toISOString() })
const vorher = { ...funktion() }
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 }))
assert.deepEqual(funktion(), vorher)
assert.equal(mails().length, 0)
assert.equal(abfragenAufAbos(), 1, 'nur die Vorauswahl: fuer das Dokument ohne Aufgabe gibt es keine frische Pruefung unter der Sperre')
})
test('Mehrere Projekte: bezahlter Aufbau, unbezahlte Bestellung und aktiver Terminplaner eines Kunden - je Dokument das Richtige', async () => {
legeFunktion(1, {}, { projekt: 'Projekt Alpha' }) // aktiv
legeAufbau(2)
legeBestellung(3)
assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, abgeschaltet: 3, mails: 2 }))
assert.equal(funktion(funktionsId(1)).disabledReason, 'abo_ende')
assert.equal(funktion(funktionsId(2)).disabledReason, 'abo_ende_vor_einrichtung')
assert.equal(funktion(funktionsId(3)).disabledReason, 'abo_ende_vor_einrichtung')
assert.equal(t19().length, 1, 'T19 nur fuer die bezahlte Einrichtung')
assert.equal(k22().length, 2, 'K22 fuer den aktiven (abo_ende) und den bezahlten Aufbau (vor_einrichtung)')
assert.ok(!funktion(funktionsId(3)).aboEndeMailAt)
})
// ---------------------------------------------------------------- Erstattungsmeldung (T19) unabhaengig vom Abo (R107, E5b)
/** Bezahlte Einrichtung, die das Abo-Ende schon abgeschaltet hat, deren T19 aber noch aussteht (Marker gemeldet und aboEndeMailAt leer). */
const legeErstattungOffen = (nr = 1, over = {}) =>
legeAufbau(nr, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString(), ...over })
/** Transport, bei dem T19 scheitert (alles andere geht in die Senke). */
const t19Scheitert = () =>
setMailTransportForTests({
sendMail: async (mail) => {
if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen')
return sink.sendMail(mail)
},
})
test('R107: T19 scheitert am Tag 1 (Abo beendet), am Tag 2 laeuft das Abo wieder - Tag 2 sendet T19 genau einmal, danach K22, das Dokument bleibt abgeschaltet, Tag 3 sendet nichts', async () => {
legeAufbau(1)
t19Scheitert()
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
let doc = funktion()
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
assert.ok(!doc.zahlungMeldung)
assert.ok(!doc.aboEndeMailAt)
assert.equal(mails().length, 0)
const disabledAt = doc.disabledAt
// Tag 2: das Wartungs-Abo laeuft wieder, der Versand geht
setMailTransportForTests(sink)
wartung('active')
aw.resetListCalls()
const tag2 = plus(JETZT, TAG)
assert.deepEqual(await laufe(tag2), ergebnis({ geprueft: 1, mails: 1 }), 'mails zaehlt K22, nicht T19')
assert.equal(abfragenAufAbos(), 0, 'der Nachlauf liest das Abo nicht')
assert.equal(t19().length, 1, 'T19 genau einmal')
assert.equal(k22().length, 1)
assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false], 'T19 vor K22')
assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/)
assert.match(k22()[0].text, /am 05\.10\.2026 beendet/, 'Enddatum aus disabledAt, nicht vom Folgetag')
assert.match(t19()[0].text, /cs_test_1/)
assert.match(t19()[0].text, /WO-4711/)
doc = funktion()
assert.equal(doc.zahlungMeldung, 'gemeldet')
assert.equal(doc.purchaseStatus, 'paid')
assert.equal(ms(doc.aboEndeMailAt), tag2.getTime())
assert.equal(doc.state, 'disabled', 'keine Wiederkehr: sie gilt nur fuer abo_ende')
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
assert.equal(doc.enabled, false)
assert.equal(doc.disabledAt, disabledAt, 'die Frist wuchs nicht')
assert.equal(aw.docs('workorders').get('t_1').status, 'Open', 'das Ticket bleibt unberuehrt')
// Tag 3: nichts mehr
const vorher = { ...doc }
assert.deepEqual(await laufe(plus(tag2, TAG)), ergebnis({ geprueft: 1 }))
assert.deepEqual(funktion(), vorher)
assert.equal(mails().length, 2)
})
test('R107: T19 scheitert weiter, obwohl das Abo laeuft - jeden Tag fehler 1, kein K22, kein Marker; sobald der Versand geht, T19 und K22', async () => {
legeAufbau(1)
t19Scheitert()
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
wartung('active')
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, fehler: 1 }))
assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1, fehler: 1 }))
assert.equal(mails().length, 0, 'auch K22 geht nicht vor T19')
assert.ok(!funktion().zahlungMeldung)
assert.ok(!funktion().aboEndeMailAt)
assert.equal(funktion().state, 'disabled')
setMailTransportForTests(sink)
assert.deepEqual(await laufe(plus(JETZT, 3 * TAG)), ergebnis({ geprueft: 1, mails: 1 }))
assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false])
assert.equal(funktion().zahlungMeldung, 'gemeldet')
assert.deepEqual(await laufe(plus(JETZT, 4 * TAG)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 2)
})
test('R107: das Abo ist nicht lesbar - T19 wird trotzdem nachgeholt; ein anderes Dokument desselben Kunden bleibt fail-closed (aktiv, fehler 1)', async () => {
legeErstattungOffen(1)
legeFunktion(2, {}, { projekt: 'Zweites Projekt' }) // aktiv: braucht die Abo-Lage
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, mails: 1, fehler: 1 }))
assert.equal(t19().length, 1)
assert.equal(k22().length, 1)
assert.equal(funktion(funktionsId(1)).zahlungMeldung, 'gemeldet')
assert.ok(funktion(funktionsId(1)).aboEndeMailAt)
assert.equal(funktion(funktionsId(2)).state, 'active', 'nie abschalten, wenn etwas nicht lesbar war')
assert.equal(mails().length, 2)
})
test('R107: der Nachlauf liest das Abo nie - ein einzelnes Dokument kostet keine Abo-Abfrage, bei zwei Dokumenten braucht nur das erledigte die Lage', async () => {
legeErstattungOffen(1)
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(abfragenAufAbos(), 0)
assert.equal(t19().length, 1)
legeErstattungOffen(2)
wartung('active')
aw.resetListCalls()
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 2, mails: 1 }), 'das erste Dokument ist erledigt und wird nur bewertet')
assert.equal(abfragenAufAbos(), 1, 'nur das erledigte Dokument braucht die Lage (Rueckkehr-Pruefung), der Nachlauf selbst keine')
assert.equal(t19().length, 2)
})
for (const [abo, vorbereitung] of [
['ohne Abo', () => {}],
['mit laufendem Abo', () => wartung('active')],
['mit beendetem Abo', () => wartung('canceled', { endedAt: JETZT.toISOString() })],
]) {
test(`R107: unbezahltes abo_ende_vor_einrichtung-Dokument ${abo} - nie T19, nie K22, nichts geschrieben`, async () => {
legeBestellung(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -TAG).toISOString() })
vorbereitung()
const vorher = { ...funktion() }
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1 }))
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
assert.deepEqual(funktion(), vorher)
assert.equal(mails().length, 0)
})
}
test('R107: bereits gemeldet - kein zweites T19, auch wenn das Abo laeuft; mit gemeldetem T19 und fehlendem K22 geht nur K22 (ohne Abo), nie T19', async () => {
legeErstattungOffen(1, ERSTATTET) // T19 gemeldet, K22 ging
wartung('active')
const vorher = { ...funktion() }
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1 }))
assert.deepEqual(funktion(), vorher)
assert.equal(mails().length, 0)
// T19 gemeldet, K22 noch offen, kein Abo: der bisherige Nachlauf (Schritt 2) sendet nur K22
await deleteDocument('subscriptions', 'sub_w')
aw.seed('portalFeatures', { ...funktion(), aboEndeMailAt: null })
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(t19().length, 0, 'T19 war schon gemeldet')
assert.equal(k22().length, 1)
assert.equal(funktion().zahlungMeldung, 'gemeldet')
assert.ok(funktion().aboEndeMailAt)
})
test('R107: Mischung - ein Kunde mit offener Erstattung, aktivem Terminplaner und Bestellung; das Abo laeuft: nur die Erstattung wird gemeldet, nichts wird abgeschaltet', async () => {
legeErstattungOffen(1)
legeFunktion(2, {}, { projekt: 'Projekt Alpha' })
legeBestellung(3)
wartung('active')
assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, mails: 1 }))
assert.equal(t19().length, 1)
assert.equal(funktion(funktionsId(2)).state, 'active')
assert.equal(funktion(funktionsId(3)).state, 'ordered')
assert.equal(abfragenAufAbos(), 1, 'die Lage wird fuer die uebrigen Dokumente einmal gelesen')
})
test('R107: Mischung ohne Abo - Erstattung, aktiver Terminplaner und Bestellung: je Dokument das Richtige, T19 genau einmal', async () => {
legeErstattungOffen(1)
legeFunktion(2, {}, { projekt: 'Projekt Alpha' })
legeBestellung(3)
assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, abgeschaltet: 2, mails: 2 }))
assert.equal(t19().length, 1)
assert.equal(k22().length, 2, 'K22 fuer die Erstattung (vor_einrichtung) und den aktiven Terminplaner (abo_ende)')
assert.equal(funktion(funktionsId(1)).zahlungMeldung, 'gemeldet')
assert.equal(funktion(funktionsId(2)).disabledReason, 'abo_ende')
assert.equal(funktion(funktionsId(3)).disabledReason, 'abo_ende_vor_einrichtung')
assert.ok(!funktion(funktionsId(3)).aboEndeMailAt)
})
test('R107: das Dokument wird nur einmal je Lauf bearbeitet - schlaegt K22 nach gesendetem T19 fehl, zaehlt fehler 1 (nicht 2); der naechste Tag ohne Abo sendet K22', async () => {
legeErstattungOffen(1)
setMailTransportForTests({
sendMail: async (mail) => {
if (K22_BETREFF.test(mail.subject)) throw new Error('Verbindung abgebrochen')
return sink.sendMail(mail)
},
})
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, fehler: 1 }))
assert.equal(t19().length, 1)
assert.equal(funktion().zahlungMeldung, 'gemeldet')
assert.ok(!funktion().aboEndeMailAt)
setMailTransportForTests(sink)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(t19().length, 1, 'kein zweites T19')
assert.equal(k22().length, 1)
})
test('R107 Rennen: das Team aktiviert zwischen Liste und Sperre - das frisch gelesene Dokument ist active, kein T19, keine Mail', async () => {
legeErstattungOffen(1)
const erg = await mitEinwurf(
liste('portalFeatures'),
async () => aw.seed('portalFeatures', { ...funktion(), state: 'active', enabled: true }),
() => laufe(),
)
assert.deepEqual(erg, ergebnis({ geprueft: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(mails().length, 0)
})
test('R107 Rennen: der Webhook meldet T19 zwischen Liste und Sperre - kein zweites T19 durch den Abgleich; ohne Abo geht K22 ueber den bisherigen Nachlauf (Schritt 2), am Folgetag nichts', async () => {
legeErstattungOffen(1)
const erg = await mitEinwurf(
liste('portalFeatures'),
async () => aw.seed('portalFeatures', { ...funktion(), zahlungMeldung: 'gemeldet' }),
() => laufe(),
)
assert.deepEqual(erg, ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(t19().length, 0, 'T19 war schon gemeldet')
assert.equal(k22().length, 1)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 1)
})
test('R107 Sperre: der Nachlauf liest das Dokument unter feature-admin:<projectId> (wartet auf die Sperre), sendet aber ausserhalb jeder Sperre', async () => {
legeErstattungOffen(1)
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(mails().length, 0)
})
// Versand: waehrenddessen darf feature-admin:<projectId> nicht gehalten sein
const sperreBeimVersand = []
setMailTransportForTests({
sendMail: async (mail) => {
const frei = await Promise.race([
withLocks(['feature-admin:p_projekt01'], async () => 'frei'),
new Promise((resolve) => setTimeout(() => resolve('gesperrt'), 150)),
])
sperreBeimVersand.push([mail.subject === T19_BETREFF ? 'T19' : 'K22', frei])
return sink.sendMail(mail)
},
})
assert.deepEqual(await lauf, ergebnis({ geprueft: 1, mails: 1 }))
assert.deepEqual(sperreBeimVersand, [['T19', 'frei'], ['K22', 'frei']])
assert.equal(funktion().zahlungMeldung, 'gemeldet')
})
test('R107: Lesefehler beim frischen Dokument - kein Wurf, fehler 1, keine Mail; der naechste Tag holt T19 und K22 nach', async () => {
legeErstattungOffen(1)
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, after: 1 }) // die Liste geht durch, das frische Lesen scheitert
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, fehler: 1 }))
assert.equal(mails().length, 0)
assert.ok(!funktion().zahlungMeldung)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }))
assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false])
})
for (const [fall, vorbereitung] of [
['Kunde ohne E-Mail-Adresse', () => aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' })],
['Kunde geloescht', () => deleteDocument('customers', KUNDE)],
]) {
test(`R107: ${fall} bei laufendem Abo - T19 geht trotzdem an das Team, kein K22, Marker gesetzt, am Folgetag nichts`, async () => {
legeErstattungOffen(1)
await vorbereitung()
wartung('active')
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 }))
assert.equal(t19().length, 1, 'das Geld hat eine Spur, auch ohne Kunden')
assert.equal(k22().length, 0)
assert.equal(funktion().zahlungMeldung, 'gemeldet')
assert.ok(funktion().aboEndeMailAt, 'kein Wiederholen')
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 1)
})
}
// ---------------------------------------------------------------- meldeAboEnde fuer abo_ende_vor_einrichtung (der Aufruf, den E9 nutzt)
test('meldeAboEnde direkt: bezahltes Dokument mit Grund abo_ende_vor_einrichtung - ein T19, ein K22 vor_einrichtung, Marker, true; zweiter Aufruf nichts', async () => {
const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString() })
const lage = { laeuft: false, art: 'beendet', endedAt: null }
let gesendet = 0
assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT, onGesendet: () => (gesendet += 1) }), true)
assert.equal(t19().length, 1)
assert.equal(k22().length, 1)
assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/)
assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false])
assert.equal(gesendet, 1, 'onGesendet zaehlt K22')
assert.equal(ms(funktion(id).aboEndeMailAt), JETZT.getTime())
assert.equal(funktion(id).zahlungMeldung, 'gemeldet')
assert.equal(funktion(id).purchaseStatus, 'paid')
assert.equal(await meldeAboEnde(funktion(id), { lage, now: plus(JETZT, TAG) }), true)
assert.equal(mails().length, 2, 'zweiter Aufruf: weder T19 noch K22')
assert.equal(ms(funktion(id).aboEndeMailAt), JETZT.getTime(), 'Marker unveraendert')
})
test('meldeAboEnde direkt: bezahlt, K22-Marker gesetzt, aber T19 noch nicht gemeldet - nur T19 (kein zweites K22, Marker unveraendert); danach nichts mehr', async () => {
const marker = plus(JETZT, -TAG).toISOString()
const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: marker, aboEndeMailAt: marker })
assert.equal(await meldeAboEnde(funktion(id), { now: JETZT }), true, 'ohne lage: bei abo_ende_vor_einrichtung wird sie nicht gebraucht')
assert.equal(t19().length, 1)
assert.equal(k22().length, 0, 'K22 ging schon')
assert.equal(funktion(id).zahlungMeldung, 'gemeldet')
assert.equal(funktion(id).aboEndeMailAt, marker, 'Marker unveraendert')
assert.equal(await meldeAboEnde(funktion(id), { now: plus(JETZT, TAG) }), true)
assert.equal(mails().length, 1)
})
test('meldeAboEnde direkt: unbezahltes ordered-Dokument - weder T19 noch K22 noch Marker, true, Dokument unveraendert', async () => {
const id = legeBestellung(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString() })
const vorher = { ...funktion(id) }
const lage = { laeuft: false, art: 'beendet', endedAt: null }
assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), true)
assert.equal(await meldeAboEnde(funktion(id), { lage, now: plus(JETZT, TAG) }), true)
assert.equal(mails().length, 0)
assert.deepEqual(funktion(id), vorher)
})
test('meldeAboEnde direkt: T19 scheitert - false, kein K22, kein Marker; der naechste Aufruf sendet T19 und K22 in dieser Reihenfolge', async () => {
const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString() })
const lage = { laeuft: false, art: 'beendet', endedAt: null }
setMailTransportForTests({ sendMail: async () => { throw new Error('Verbindung abgebrochen') } })
assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), false)
assert.ok(!funktion(id).zahlungMeldung)
assert.ok(!funktion(id).aboEndeMailAt)
setMailTransportForTests(sink)
assert.equal(await meldeAboEnde(funktion(id), { lage, now: plus(JETZT, TAG) }), true)
assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false])
assert.ok(funktion(id).aboEndeMailAt)
})
test('meldeAboEnde direkt: T19 war schon draussen (gemeldet) - kein zweites T19, K22 und Marker folgen; K22 scheitert - Marker leer, T19 nicht wiederholt', async () => {
const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString(), zahlungMeldung: 'gemeldet' })
const lage = { laeuft: false, art: 'beendet', endedAt: null }
setMailTransportForTests({ sendMail: async () => { throw new Error('Verbindung abgebrochen') } })
assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), false, 'K22 scheitert')
assert.ok(!funktion(id).aboEndeMailAt)
setMailTransportForTests(sink)
assert.equal(await meldeAboEnde(funktion(id), { lage, now: plus(JETZT, TAG) }), true)
assert.equal(t19().length, 0, 'T19 war schon gemeldet')
assert.equal(k22().length, 1)
assert.ok(funktion(id).aboEndeMailAt)
})
test('meldeAboEnde direkt: bezahlt, aber Kunde nicht mehr vorhanden - T19 geht trotzdem an das Team, kein K22, Marker gesetzt', async () => {
const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString() })
await deleteDocument('customers', KUNDE)
const lage = { laeuft: false, art: 'beendet', endedAt: null }
assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), true)
assert.equal(t19().length, 1, 'das Geld hat eine Spur, auch ohne Kunden')
assert.equal(k22().length, 0)
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, { ...ERSTATTET, state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: tag }) // bezahlt, Erstattung gemeldet, K22 ging
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:<projectId>, 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 <silke@example.com> 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)
})
// ---------------------------------------------------------------- Sofortpfad: Widerruf des Wechsels (E9)
// endeTermineFuerKunde(customerId, { now }) beendet die Terminplaner eines Kunden sofort, ohne das Abo zu lesen (der Spiegel ist
// veraltet), und meldet danach ueber meldeAboEnde (nach der Sperre feature-admin). Der Tagesabgleich laesst das Ergebnis in Ruhe.
const NOTIZ_WECHSEL = 'Abgeschaltet: Wechsel widerrufen'
/** endeTermineFuerKunde "zur Zeit now": die Wanduhr steht fest, damit disable() (new Date()) denselben Zeitpunkt schreibt. */
async function beende(kunde = KUNDE, now = JETZT) {
mock.timers.enable({ apis: ['Date'], now: now.getTime() })
try {
return await endeTermineFuerKunde(kunde, { now })
} finally {
mock.timers.reset()
}
}
test('Sofortpfad: aktiver, bezahlter Terminplaner => abgeschaltet (abo_ende, Notiz Wechsel widerrufen), K22 Block Hosting, Marker; das Abo wird nie gelesen', async () => {
legeFunktion(1)
legeDaten(1)
const daten = datenStand(1)
assert.deepEqual(await beende(), { beendet: [F1] })
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, NOTIZ_WECHSEL)
assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker nach dem Versand, mit now')
assert.equal(abfragenAufAbos(), 0, 'weder Abo noch Spiegel gelesen')
assert.equal(aw.listCalls('contractEvents').length, 0)
assert.deepEqual(datenStand(1), daten, 'Einstellung und Buchungen bleiben (30 Tage Frist)')
assert.equal(k22().length, 1)
const mail = k22()[0]
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com'])
assert.match(mail.text, /weil Dein Abo ein Hosting-Abo ist/, 'Block hosting: der Wechsel wurde widerrufen, das Abo bleibt Hosting')
assert.match(mail.text, /Ab 04\.11\.2026 löschen wir/, 'Loeschdatum = Sofortpfad + 30 Tage')
assert.equal(t19().length, 0, 'aktiv und bezahlt hat nichts zu erstatten')
// der Tagesabgleich findet nichts mehr zu tun, auch ohne Abo
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
assert.equal(k22().length, 1)
assert.equal(funktion().adminNote, NOTIZ_WECHSEL)
})
test('Sofortpfad: Pilot und Kulanz (aktiv ohne Kauf), News, off, schon Abgeschaltete und andere Kunden bleiben unberuehrt', async () => {
legeFunktion(1) // bezahlt: wird beendet
legeFunktion(2, { unlockedByPurchase: false, purchaseStatus: '' }) // Pilot
legeFunktion(3, { featureKey: 'news' })
legeFunktion(4, { state: 'off', enabled: false, unlockedByPurchase: false })
legeFunktion(5, { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: plus(JETZT, -2 * TAG).toISOString() })
legeFunktion(6, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -2 * TAG).toISOString() }) // K22 noch offen: Sache des Tagesabgleichs
aw.seed('customers', { $id: 'c_anderer', name: 'Anderer Kunde', email: 'anderer@example.com' })
legeFunktion(7, {}, { kunde: 'c_anderer' })
const unberuehrt = [2, 3, 4, 5, 6, 7].map((nr) => ({ ...funktion(funktionsId(nr)) }))
assert.deepEqual(await beende(), { beendet: [F1] })
assert.deepEqual([2, 3, 4, 5, 6, 7].map((nr) => funktion(funktionsId(nr))), unberuehrt)
assert.equal(funktion().state, 'disabled')
assert.equal(mails().length, 1)
assert.deepEqual(mails()[0].to.map((a) => a.address), ['silke@example.com'])
assert.deepEqual(await beende('c_ohne_funktionen'), { beendet: [] })
assert.deepEqual(await beende(''), { beendet: [] })
})
test('Sofortpfad: mehrere Projekte des Kunden, aktiv und im Aufbau, jedes unter seinem Grund', async () => {
legeFunktion(1)
legeAufbau(2)
legeBestellung(3)
assert.deepEqual((await beende()).beendet.sort(), [1, 2, 3].map(funktionsId))
assert.deepEqual([1, 2, 3].map((nr) => funktion(funktionsId(nr)).disabledReason), ['abo_ende', 'abo_ende_vor_einrichtung', 'abo_ende_vor_einrichtung'])
assert.equal(t19().length, 1, 'nur die bezahlte Einrichtung wird erstattet')
assert.equal(k22().length, 2, 'K22 fuer den aktiven (abo_ende) und die bezahlte Einrichtung (vor_einrichtung), nicht fuer das unbezahlte ordered')
assert.equal(abfragenAufAbos(), 0)
})
test('Sofortpfad, bezahlter Aufbau (setup): abgeschaltet vor der Einrichtung, genau ein T19 (Ticket offen), K22 vor_einrichtung, Marker, gemeldet; der Tagesabgleich sendet nichts mehr', async () => {
legeAufbau(1)
const ticketVorher = { ...aw.docs('workorders').get('t_1') }
assert.deepEqual(await beende(), { beendet: [F1] })
const doc = funktion()
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
assert.equal(doc.adminNote, NOTIZ_WECHSEL)
assert.equal(doc.purchaseStatus, 'paid')
assert.equal(doc.zahlungMeldung, 'gemeldet')
assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime())
assert.equal(doc.stripeSessionId, 'cs_test_1')
assert.equal(t19().length, 1)
assert.match(t19()[0].text, /Ticket noch offen/)
assert.match(t19()[0].text, /WO-4711/)
assert.doesNotMatch(t19()[0].text, /Zahlung nach dem Abo-Ende/)
assert.equal(k22().length, 1)
assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/)
assert.doesNotMatch(k22()[0].text, /Was jetzt gilt|löschen wir/)
assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false], 'zuerst T19, dann K22')
assert.deepEqual(aw.docs('workorders').get('t_1'), ticketVorher, 'das Ticket bleibt, wie es ist')
// der folgende Abgleichslauf: weder T19 noch K22 (auch wenn der Spiegel noch Wartung sagt, der Wechsel aber widerrufen ist)
wartung('active')
wechselEreignis('widerrufen')
const vorher = { ...funktion() }
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
assert.deepEqual(funktion(), vorher)
assert.equal(t19().length, 1)
assert.equal(k22().length, 1)
})
test('Sofortpfad, unbezahltes ordered: abgeschaltet, kein T19, kein K22, kein Marker; kommt die Zahlung danach, meldet das Ereignis T19 genau einmal und der Abgleich K22', async () => {
legeBestellung(1)
assert.deepEqual(await beende(), { beendet: [F1] })
let doc = funktion()
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
assert.equal(doc.adminNote, NOTIZ_WECHSEL)
assert.ok(!doc.aboEndeMailAt, 'kein Marker')
assert.ok(!doc.zahlungMeldung)
assert.equal(doc.stripeSessionId, 'cs_test_1', 'die offene Sitzung wird nicht angefasst')
assert.equal(doc.unlockedByPurchase, false)
assert.equal(mails().length, 0)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }), 'der Abgleich laesst es in Ruhe')
assert.equal(mails().length, 0)
await handleFeatureCheckoutEvent({
type: 'checkout.session.completed',
data: { object: { id: 'cs_test_1', payment_status: 'paid', metadata: { customerId: KUNDE, projectId: 'p_projekt01', featureKey: 'termine' } } },
})
assert.equal(t19().length, 1)
assert.match(t19()[0].text, /Zahlung nach dem Abo-Ende/)
assert.equal(k22().length, 0, 'K22 gehoert dem Abgleich')
assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(t19().length, 1, 'kein zweites T19')
assert.equal(k22().length, 1)
assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/)
assert.deepEqual(await laufe(plus(JETZT, 3 * TAG)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 2)
})
test('Sofortpfad: T19 scheitert => beendet wird gemeldet, ohne Wurf; K22 und Marker fehlen; der Tagesabgleich sendet T19 und K22 nach, in dieser Reihenfolge', async () => {
legeAufbau(1)
setMailTransportForTests({
sendMail: async (mail) => {
if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen')
return sink.sendMail(mail)
},
})
assert.deepEqual(await beende(), { beendet: [F1] })
let doc = funktion()
assert.equal(doc.state, 'disabled', 'abgeschaltet wird trotzdem')
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
assert.ok(!doc.zahlungMeldung, 'T19 nicht gesendet: kein Marker gemeldet')
assert.ok(!doc.aboEndeMailAt)
assert.equal(mails().length, 0, 'auch K22 geht erst nach T19')
assert.ok(log.error.mock.calls.length >= 1)
const disabledAt = doc.disabledAt
setMailTransportForTests(sink)
const naechster = plus(JETZT, TAG)
assert.deepEqual(await laufe(naechster), ergebnis({ geprueft: 1, mails: 1 }))
assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false])
doc = funktion()
assert.equal(doc.zahlungMeldung, 'gemeldet')
assert.equal(ms(doc.aboEndeMailAt), naechster.getTime())
assert.equal(doc.disabledAt, disabledAt, 'die Frist wuchs nicht')
assert.deepEqual(await laufe(plus(naechster, TAG)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 2)
})
test('Sofortpfad: K22 scheitert bei einem aktiven Terminplaner => beendet, kein Marker; der Tagesabgleich holt K22 nach', async () => {
legeFunktion(1)
wartung('active') // Spiegel noch Wartung, der Wechsel ist widerrufen: der Abgleich nimmt denselben Block hosting
wechselEreignis('widerrufen')
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } })
assert.deepEqual(await beende(), { beendet: [F1] })
assert.equal(funktion().state, 'disabled')
assert.ok(!funktion().aboEndeMailAt)
const disabledAt = funktion().disabledAt
setMailTransportForTests(sink)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(k22().length, 1)
assert.match(k22()[0].text, /weil Dein Abo ein Hosting-Abo ist/)
assert.match(k22()[0].text, /Ab 04\.11\.2026 löschen wir/, 'Loeschdatum aus dem Sofortpfad (disabledAt), nicht vom Folgetag')
assert.equal(funktion().disabledAt, disabledAt, 'die Frist wuchs nicht')
assert.ok(funktion().aboEndeMailAt)
})
test('Sofortpfad: scheitert ein Dokument, werden die anderen trotzdem beendet; der Fehler steht im Log (ohne Adresse)', async () => {
legeFunktion(1)
legeFunktion(2)
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
const erg = await beende()
assert.equal(erg.beendet.length, 1)
const [beendet] = erg.beendet
const uebrig = [F1, funktionsId(2)].find((id) => id !== beendet)
assert.equal(funktion(beendet).state, 'disabled')
assert.equal(funktion(uebrig).state, 'active', 'das gescheiterte bleibt aktiv (der Tagesabgleich holt es nach)')
assert.equal(k22().length, 1)
assert.ok(log.error.mock.calls.length >= 1)
// ein zweiter Aufruf beendet den Rest
assert.deepEqual(await beende(), { beendet: [uebrig] })
assert.equal(k22().length, 2)
})
test('Sofortpfad: Dokumentenliste nicht lesbar => kein Wurf, nichts beendet, Fehler im Log', async () => {
legeFunktion(1)
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'kaputt' })
assert.deepEqual(await beende(), { beendet: [] })
assert.equal(funktion().state, 'active')
assert.equal(mails().length, 0)
assert.ok(log.error.mock.calls.length >= 1)
assert.deepEqual(await beende(), { beendet: [F1] }, 'danach geht es wieder')
})
test('Sofortpfad: ungueltiges now wirft nicht (die Meldung scheitert, das Dokument ist abgeschaltet)', async () => {
legeFunktion(1)
mock.timers.enable({ apis: ['Date'], now: JETZT.getTime() })
let erg
try {
erg = await endeTermineFuerKunde(KUNDE, { now: 'gestern' })
} finally {
mock.timers.reset()
}
assert.deepEqual(erg, { beendet: [F1] })
assert.equal(funktion().state, 'disabled')
assert.ok(!funktion().aboEndeMailAt)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }))
})
test('Sofortpfad: frisch gelesen unter der Sperre feature-admin - hat das Team zwischen Liste und Sperre abgeschaltet, bleibt dessen Grund', async () => {
legeFunktion(1)
const teamAus = { ...funktion(), state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: plus(JETZT, -TAG).toISOString() }
const erg = await mitEinwurf(liste('portalFeatures'), () => aw.seed('portalFeatures', teamAus), () => beende())
assert.deepEqual(erg, { beendet: [] })
assert.deepEqual(funktion(), { ...funktion(), ...teamAus })
assert.equal(funktion().disabledReason, 'team')
assert.equal(mails().length, 0)
})
test('Sofortpfad: eine Zahlung zwischen Liste und Sperre (ordered => setup, bezahlt) wird als bezahlt behandelt: T19 und K22 vor_einrichtung', async () => {
legeBestellung(1)
aw.seed('workorders', { $id: 't_1', woid: 'WO-4711', status: 'Open', topic: 'Terminplaner einbauen – Praxis Muster' })
const bezahlt = { ...funktion(), state: 'setup', unlockedByPurchase: true, purchaseStatus: 'paid', ticketId: 't_1', stripeInvoiceId: 'in_1', invoiceUrl: 'https://invoice.stripe.test/in_1' }
const erg = await mitEinwurf(liste('portalFeatures'), () => aw.seed('portalFeatures', bezahlt), () => beende())
assert.deepEqual(erg, { beendet: [F1] })
assert.equal(funktion().state, 'disabled')
assert.equal(funktion().zahlungMeldung, 'gemeldet')
assert.equal(t19().length, 1)
assert.equal(k22().length, 1)
assert.ok(funktion().aboEndeMailAt)
})
test('Sofortpfad: wartet auf die Sperre feature-admin des Projekts; T19 und K22 gehen erst nach ihrer Freigabe (nie innerhalb)', async () => {
legeAufbau(1)
legeFunktion(2)
const sperre = (nr) => `feature-admin:${projektId(nr)}`
// 1) eine fremde Sperre haelt das Dokument auf
let freigeben
const gehalten = withLocks([sperre(1)], () => new Promise((r) => (freigeben = r)))
await new Promise((r) => setTimeout(r, 10))
const lauf = beende()
await new Promise((r) => setTimeout(r, 60))
assert.equal(funktion().state, 'setup', 'solange die Sperre gehalten wird, bleibt das Dokument, wie es ist')
assert.equal(mails().length, 0)
freigeben()
await gehalten
assert.deepEqual((await lauf).beendet.sort(), [F1, funktionsId(2)].sort())
assert.equal(funktion().state, 'disabled')
// 2) beim Versand ist die Sperre frei (withLocks ist nicht wiederbetretbar: innerhalb haengte jede Mail)
const frei = []
for (const nr of [1, 2]) {
await deleteDocument('portalFeatures', funktionsId(nr))
}
legeAufbau(1)
legeFunktion(2)
setMailTransportForTests({
sendMail: async (mail) => {
const projekte = [1, 2].map((nr) => Promise.race([withLocks([sperre(nr)], async () => true), new Promise((r) => setTimeout(() => r(false), 150))]))
frei.push([mail.subject, ...(await Promise.all(projekte))])
return sink.sendMail(mail)
},
})
await beende()
assert.equal(frei.length, 3, 'T19 und zwei K22')
assert.ok(frei.every(([, ...rest]) => rest.every(Boolean)), JSON.stringify(frei))
})
// ---------------------------------------------------------------- Zahlung nach dem Abo-Ende: Webhook und Abgleich zusammen (E10)
//
// Review Focus 1: eine Zahlung, die nach einem Zustandswechsel eintrifft, hinterlaesst immer eine Spur (Ticket ODER T16/T19 an das
// Team) und nie ein Doppel. Das Verhalten selbst steckt in siteFeatures.js (markPaid-Fall, meldeErstattung, onFeaturePaid, B10);
// diese Tests lassen Webhook (handleFeatureCheckoutEvent) und Abgleich (gleicheAboAb) gegeneinander laufen.
const SITZUNG = 'cs_test_1'
const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } })
const paidSession = (id = SITZUNG, projectId = 'p_projekt01', extra = {}) => ({
id,
object: 'checkout.session',
mode: 'payment',
payment_status: 'paid',
metadata: { customerId: KUNDE, projectId, featureKey: 'termine' },
...extra,
})
const COMPLETED = 'checkout.session.completed'
const ASYNC_BEZAHLT = 'checkout.session.async_payment_succeeded'
/** Das Stripe-Ereignis einer bezahlten Sitzung (noch einmal mit demselben Ereignis aufrufen = Wiederholung durch Stripe). */
const zahlungsEreignis = (typ = COMPLETED, session = paidSession()) => event(typ, session)
const T16_BETREFF = 'Neue Funktion bestellt: Terminplaner'
const K15_BETREFF = 'Deine Bestellung: Terminplaner'
const t16 = () => mails().filter((m) => m.subject === T16_BETREFF)
const k15 = () => mails().filter((m) => m.subject === K15_BETREFF)
const betreffs = () => mails().map((m) => m.subject)
const tickets = () => aw.docs('workorders').size
/** Das Anlegen eines Tickets schreibt eine Zeile ins Log (ticketAdmin); die Tests mit Ticket halten die Ausgabe sauber. Zurueck setzt afterEach (restoreAll). */
const stilleTicketLog = () => mock.method(console, 'log', () => {})
const TEAM = () => [...config.chatNotify.emails].sort()
const an = (mail) => mail.to.map((a) => a.address).sort()
/** Unbezahlte Bestellung, deren Wartungs-Abo schon beendet ist: der Abgleich (ohne Abo) hat sie abgeschaltet, die Zahlung steht noch aus. */
async function bestellungNachAboEnde() {
legeBestellung(1)
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1 }))
assert.equal(funktion().state, 'disabled')
assert.equal(funktion().disabledReason, 'abo_ende_vor_einrichtung')
assert.equal(funktion().unlockedByPurchase, false)
assert.equal(mails().length, 0)
}
test('E10: Zahlung nach dem Abschalten - genau ein T19 (zahlung_nach_ende, Sitzung), kein Ticket, kein K15/T16, bezahlt gebucht, abgeschaltet; Wiederholung und async_payment_succeeded melden nichts mehr', async () => {
await bestellungNachAboEnde()
const ticketsVorher = tickets()
await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED))
assert.equal(t19().length, 1, 'genau ein T19')
const mailT19 = t19()[0]
assert.deepEqual(an(mailT19), TEAM(), 'T19 geht an das Team, nicht an den Kunden')
assert.match(mailT19.text, /Zahlung nach dem Abo-Ende/, 'Block zahlung_nach_ende')
assert.match(mailT19.text, /cs_test_1/, 'die Sitzungs-ID steht drin')
assert.match(mailT19.text, /Silke Müller/)
assert.match(mailT19.text, /Praxis Muster/)
assert.doesNotMatch(mailT19.text, /Ticket noch offen/, 'es gibt kein Ticket')
assert.deepEqual(betreffs(), [T19_BETREFF], 'sonst keine Mail: weder K15 noch T16 noch K22')
assert.equal(k15().length + t16().length, 0)
assert.equal(tickets(), ticketsVorher, 'kein Eintrag in workorders')
assert.equal(tickets(), 0)
let doc = funktion()
assert.equal(doc.unlockedByPurchase, true)
assert.equal(doc.purchaseStatus, 'paid')
assert.equal(doc.zahlungMeldung, 'gemeldet')
assert.equal(doc.state, 'disabled', 'der Zustand bleibt abgeschaltet')
assert.equal(doc.enabled, false)
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
assert.ok(!doc.ticketId, 'kein Ticket am Dokument')
assert.equal(doc.stripeSessionId, SITZUNG)
assert.match(doc.adminNote, /Zahlung eingegangen \(Sitzung cs_test_1\), nichts eingerichtet/, 'die Spur steht in der Notiz')
// dasselbe Ereignis noch einmal (Stripe-Wiederholung) und das SEPA-Folgeereignis: stumm, nichts geschrieben
const gebucht = { ...doc }
await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED))
await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT))
await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT))
assert.equal(t19().length, 1, 'nie ein zweites T19')
assert.deepEqual(betreffs(), [T19_BETREFF])
assert.deepEqual(funktion(), gebucht, 'nichts neu verbucht')
assert.equal(tickets(), 0)
})
test('E10: T19-Versand scheitert - der Webhook wirft (500), die Zahlung ist gebucht, die Meldung offen; jede Wiederholung wirft weiter, die erste mit funktionierendem Versand sendet genau ein T19', async () => {
await bestellungNachAboEnde()
t19Scheitert()
await assert.rejects(handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)), /Meldung zur Zahlung cs_test_1 \(abo_ende\) nicht gesendet/)
let doc = funktion()
assert.equal(doc.unlockedByPurchase, true, 'die Zahlung ist trotzdem gebucht')
assert.equal(doc.purchaseStatus, 'paid')
assert.equal(doc.zahlungMeldung, 'abo_ende', 'der Fall bleibt offen (nicht gemeldet)')
assert.equal(doc.state, 'disabled')
assert.equal(mails().length, 0)
assert.equal(tickets(), 0)
const gebucht = { ...doc }
// Stripe wiederholt, der Versand geht weiter nicht: wieder 500, nichts neu verbucht
await assert.rejects(handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)), /nicht gesendet/)
await assert.rejects(handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT)), /nicht gesendet/)
assert.deepEqual(funktion(), gebucht)
assert.equal(mails().length, 0)
// der Versand geht wieder: genau ein T19, danach ist die Wiederholung stumm
setMailTransportForTests(sink)
await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED))
assert.equal(t19().length, 1)
assert.match(t19()[0].text, /Zahlung nach dem Abo-Ende/)
assert.match(t19()[0].text, /cs_test_1/)
doc = funktion()
assert.equal(doc.zahlungMeldung, 'gemeldet')
assert.equal(doc.purchaseStatus, 'paid')
assert.equal(doc.state, 'disabled')
await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT))
assert.deepEqual(betreffs(), [T19_BETREFF])
assert.equal(tickets(), 0)
})
test('E10: Folgetag-Abgleich nach der Zahlung - K22 vor_einrichtung an den Kunden, aboEndeMailAt gesetzt, kein zweites T19 (Marker gemeldet), danach nichts mehr', async () => {
await bestellungNachAboEnde()
await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED))
assert.equal(t19().length, 1)
assert.ok(!funktion().aboEndeMailAt, 'K22 gehoert dem Abgleich')
const tag2 = plus(JETZT, TAG)
assert.deepEqual(await laufe(tag2), ergebnis({ geprueft: 1, mails: 1 }), 'mails zaehlt K22, nicht T19')
assert.equal(k22().length, 1)
const mailK22 = k22()[0]
assert.deepEqual(an(mailK22), ['silke@example.com'], 'K22 geht an den Kunden')
assert.equal(mailK22.subject, 'Dein Terminplaner wurde beendet – Praxis Muster')
assert.match(mailK22.text, /noch vor der Einrichtung Deines Terminplaners/, 'Block vor_einrichtung')
assert.match(mailK22.text, /erstatten Dir die Einrichtung \(149 €\) vollständig/)
assert.match(mailK22.text, /am 05\.10\.2026 beendet/, 'Enddatum aus disabledAt')
assert.doesNotMatch(mailK22.text, /Was jetzt gilt|löschen wir/, 'es gibt nichts zu loeschen')
assert.equal(t19().length, 1, 'kein zweites T19')
assert.deepEqual(betreffs().map((s) => s === T19_BETREFF), [true, false], 'zuerst T19 (Webhook), dann K22 (Abgleich)')
const doc = funktion()
assert.equal(ms(doc.aboEndeMailAt), tag2.getTime())
assert.equal(doc.zahlungMeldung, 'gemeldet')
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
// Tag 3 und eine spaete Wiederholung des Ereignisses: nichts mehr
const vorher = { ...doc }
assert.deepEqual(await laufe(plus(tag2, TAG)), ergebnis({ geprueft: 1 }))
await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT))
assert.equal(mails().length, 2)
assert.deepEqual(funktion(), vorher)
assert.equal(tickets(), 0)
})
test('E10: Abgleich VOR der Wiederholung des Webhooks - Schritt 2b meldet T19 (die erste Verbuchung hat bezahlt), K22 folgt, die Wiederholung bleibt stumm', async () => {
await bestellungNachAboEnde()
t19Scheitert()
await assert.rejects(handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)), /nicht gesendet/)
assert.equal(funktion().unlockedByPurchase, true)
assert.equal(funktion().zahlungMeldung, 'abo_ende')
assert.equal(mails().length, 0)
// der Versand geht wieder, der Tagesabgleich kommt vor Stripes Wiederholung
setMailTransportForTests(sink)
const tag2 = plus(JETZT, TAG)
assert.deepEqual(await laufe(tag2), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(t19().length, 1, 'der Abgleich meldet T19')
assert.match(t19()[0].text, /Zahlung nach dem Abo-Ende/, 'als Zahlung nach dem Abo-Ende (der offene Fall steht am Dokument)')
assert.match(t19()[0].text, /cs_test_1/)
assert.equal(k22().length, 1)
assert.deepEqual(betreffs().map((s) => s === T19_BETREFF), [true, false], 'T19 vor K22')
const doc = funktion()
assert.equal(doc.zahlungMeldung, 'gemeldet')
assert.equal(ms(doc.aboEndeMailAt), tag2.getTime())
assert.equal(doc.state, 'disabled')
// jetzt kommt die Wiederholung des Webhooks: kein Wurf, kein zweites T19
const vorher = { ...doc }
await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED))
await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT))
assert.equal(t19().length, 1, 'nie doppelt')
assert.equal(mails().length, 2)
assert.deepEqual(funktion(), vorher)
assert.equal(tickets(), 0)
})
test('E10: Abgleich und Wiederholung des Webhooks gleichzeitig (Promise.all) - insgesamt ein T19, ein K22, kein Wurf', async () => {
await bestellungNachAboEnde()
t19Scheitert()
await assert.rejects(handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)), /nicht gesendet/)
setMailTransportForTests(sink)
const tag2 = plus(JETZT, TAG)
const [erg] = await Promise.all([laufe(tag2), handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED))])
assert.deepEqual(erg, ergebnis({ geprueft: 1, mails: 1 }), 'der Abgleich sendet K22, T19 hoechstens einmal, gleich wer zuerst kommt')
assert.equal(t19().length, 1, 'insgesamt ein T19')
assert.equal(k22().length, 1)
assert.equal(mails().length, 2)
const doc = funktion()
assert.equal(doc.zahlungMeldung, 'gemeldet')
assert.equal(ms(doc.aboEndeMailAt), tag2.getTime())
assert.deepEqual(await laufe(plus(tag2, TAG)), ergebnis({ geprueft: 1 }))
await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT))
assert.equal(mails().length, 2)
})
test('E10: erste Zahlung und Tagesabgleich gleichzeitig (Promise.all) - gleich in welcher Reihenfolge: ein T19, nach dem naechsten Lauf ein K22, nie ein Doppel', async () => {
await bestellungNachAboEnde()
const tag2 = plus(JETZT, TAG)
await Promise.all([laufe(tag2), handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED))])
assert.equal(t19().length, 1, 'genau ein T19 (Webhook oder Abgleich, nie beide)')
assert.equal(funktion().purchaseStatus, 'paid')
assert.equal(funktion().unlockedByPurchase, true)
assert.equal(funktion().zahlungMeldung, 'gemeldet')
assert.equal(tickets(), 0)
await laufe(plus(tag2, TAG))
await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)) // Stripe-Wiederholung
await laufe(plus(tag2, 2 * TAG))
assert.equal(t19().length, 1)
assert.equal(k22().length, 1, 'K22 genau einmal')
assert.equal(mails().length, 2)
assert.equal(k15().length + t16().length, 0)
assert.equal(funktion().state, 'disabled')
})
test('E10: Zahlung auf ein vom Team abgeschaltetes Dokument (Grund team) - T16 "abgeschaltet" wie in B10b, kein T19, kein Ticket, der Abgleich fasst es nie an', async () => {
legeBestellung(1)
await disable(F1, { by: 'Kenso', reason: 'team' })
assert.equal(funktion().disabledReason, 'team')
await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED))
assert.equal(t16().length, 1, 'genau ein T16')
assert.deepEqual(an(t16()[0]), TEAM())
assert.match(t16()[0].text, /Bezahlt, aber schon vorhanden/, 'Block bereits_aktiv')
assert.match(t16()[0].text, /abgeschaltet/, 'Zustand abgeschaltet')
assert.match(t16()[0].text, /cs_test_1/)
assert.equal(t19().length, 0, 'kein T19: das Abo-Ende ist nicht der Grund')
assert.equal(k15().length, 0)
assert.deepEqual(betreffs(), [T16_BETREFF])
assert.equal(tickets(), 0)
const doc = funktion()
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'team')
assert.equal(doc.unlockedByPurchase, true)
assert.equal(doc.purchaseStatus, 'paid')
assert.equal(doc.zahlungMeldung, 'gemeldet')
// der Abgleich (ohne Abo) laesst ein Team-Dokument in Ruhe: keine Abo-Abfrage, keine Mail; die Wiederholung ist stumm
aw.resetListCalls()
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis())
assert.equal(abfragenAufAbos(), 0)
await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT))
assert.deepEqual(betreffs(), [T16_BETREFF])
assert.deepEqual(funktion(), doc)
})
for (const [name, legeDokument] of [
['ordered', () => legeBestellung(1)],
['setup ohne Ticket (Ticket fehlt, Stripe wiederholt)', () => legeFunktion(1, { state: 'setup', enabled: false, stripeSessionId: SITZUNG })],
]) {
test(`E10: Zahlung bei laufendem Abo (${name}) - unveraendert: Ticket, K15 an den Kunden, T16 ohne kein_abo, kein T19; die Wiederholung legt nichts doppelt an`, async () => {
stilleTicketLog()
wartung('active')
legeDokument()
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1 }), 'der Abgleich fasst eine Bestellung bei laufendem Abo nicht an')
assert.equal(funktion().state, name === 'ordered' ? 'ordered' : 'setup')
const ereignis = zahlungsEreignis(COMPLETED)
await handleFeatureCheckoutEvent(ereignis)
assert.equal(tickets(), 1, 'ein Einrichtungs-Ticket')
const doc = funktion()
assert.equal(doc.state, 'setup')
assert.equal(doc.unlockedByPurchase, true)
assert.equal(doc.purchaseStatus, 'paid')
assert.ok(aw.docs('workorders').has(doc.ticketId), 'das Ticket haengt am Dokument')
assert.ok(!doc.zahlungMeldung, 'kein Zahlungsfall')
assert.equal(k15().length, 1)
assert.deepEqual(an(k15()[0]), ['silke@example.com'])
assert.equal(t16().length, 1)
assert.deepEqual(an(t16()[0]), TEAM())
assert.doesNotMatch(t16()[0].text, /Kein laufendes Wartungs-Abo/, 'das Abo laeuft')
assert.doesNotMatch(t16()[0].text, /Bezahlt, aber schon vorhanden/)
assert.match(t16()[0].text, new RegExp(`Ticket: ${aw.docs('workorders').get(doc.ticketId).woid}\\b`), 'T16 nennt das Ticket')
assert.equal(t19().length, 0)
assert.equal(mails().length, 2)
// Stripe wiederholt: kein zweites Ticket, keine weitere Mail; der Abgleich bleibt dabei ruhig
await handleFeatureCheckoutEvent(ereignis)
await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT))
assert.equal(tickets(), 1)
assert.equal(mails().length, 2)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 2)
assert.equal(funktion().state, 'setup')
})
}
test('E10: Rennen - Zahlung VOR dem Abgleich: Ticket, K15 und T16 (mit kein_abo), dann der Abgleich: T19 und K22; Stripe-Retry desselben Ereignisses ist stumm', async () => {
stilleTicketLog()
legeBestellung(1) // das Wartungs-Abo ist beendet (kein Abo-Dokument), die Bestellung wartet noch auf die Zahlung
const ereignis = zahlungsEreignis(COMPLETED)
await handleFeatureCheckoutEvent(ereignis)
assert.equal(funktion().state, 'setup', 'gebucht und im Aufbau')
assert.equal(tickets(), 1, 'Ticket angelegt')
assert.equal(k15().length, 1)
assert.equal(t16().length, 1)
assert.match(t16()[0].text, /Kein laufendes Wartungs-Abo/, 'T16 sagt dem Team, dass das Abo fehlt')
assert.equal(t19().length, 0, 'noch kein T19: das Dokument ist nicht abgeschaltet')
const ticketId = funktion().ticketId
assert.ok(ticketId)
// der Abgleich kommt danach: bezahlter Aufbau ohne Abo => abgeschaltet, T19 (ohne "nach dem Abo-Ende"), K22 vor_einrichtung
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(t19().length, 1, 'T19 hoechstens einmal')
assert.doesNotMatch(t19()[0].text, /Zahlung nach dem Abo-Ende/, 'die Zahlung kam vor dem Abo-Ende')
assert.match(t19()[0].text, /Ticket noch offen/)
assert.equal(k22().length, 1)
assert.equal(tickets(), 1, 'kein zweites Ticket')
assert.equal(aw.docs('workorders').get(ticketId).status, 'Open', 'das Ticket bleibt offen, das Team erstattet')
let doc = funktion()
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
assert.equal(doc.zahlungMeldung, 'gemeldet')
assert.equal(doc.purchaseStatus, 'paid')
assert.equal(doc.ticketId, ticketId)
assert.equal(mails().length, 4, 'K15, T16, T19, K22')
// Stripe wiederholt dasselbe Ereignis (und das SEPA-Folgeereignis): stumm, nichts geschrieben, kein zweites Ticket
const vorher = { ...doc }
await handleFeatureCheckoutEvent(ereignis)
await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT))
assert.equal(mails().length, 4)
assert.equal(t19().length, 1)
assert.equal(tickets(), 1)
assert.deepEqual(funktion(), vorher)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 4)
})