Files
Webklar-Kundenbereich/test/termineAboAbgleich.test.js
T
ClaudeandClaude Sonnet 5.5 addb88d5fd Abo-Abgleich: Abo endet vor der Einrichtung ⇒ abgeschaltet, T19 und K22
Terminplaner TP2 E5. Schritt 1b in gleicheAboAb: Dokumente im Zustand ordered
oder setup werden bei fehlendem Wartungs-Abo unter feature-admin:<projectId> nach
frischem Lesen von Dokument und Abo mit Grund abo_ende_vor_einrichtung abgeschaltet.
Die Meldelogik liegt in meldeAboEnde (auch für den Sofortpfad E9): bei bezahlter
Einrichtung zuerst T19 über meldeErstattung (außerhalb jeder feature-admin-Sperre),
dann K22 mit Block vor_einrichtung, dann der Marker aboEndeMailAt; scheitert T19,
folgen weder K22 noch Marker und der nächste Tag wiederholt beides. Ein unbezahltes
ordered-Dokument wird nur abgeschaltet. Schritt 2 holt auch abo_ende_vor_einrichtung
nach. Die Stripe-Sitzung und das Ticket bleiben unangetastet.

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

936 lines
47 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): aktive Terminplaner ohne laufendes
// Wartungs-Abo abschalten (Schritt 1), Einrichtungen, die das Abo-Ende erreicht, abschalten und erstatten (Schritt 1b, E5) und die
// Mail K22 nachholen (Schritt 2). Aufbau wie termineJobs.test.js: Nachbau von Appwrite mit Schema und Standard-Limit 25, Mails in
// der Senke, feste Zeit JETZT. Stripe wird nie erreicht (Port 1).
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
termineConfig: schemaKeys('termineConfig'),
},
defaultLimit: 25,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { subscriptionDoc, hostingDoc, wartungDoc } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { disable, activateClosedTickets, handleFeatureCheckoutEvent } = await import('../server/services/siteFeatures.js')
const { gleicheAboAb, meldeAboEnde } = await import('../server/services/termine/aboAbgleich.js')
after(() => setMailTransportForTests(null))
const TAG = 24 * 60 * 60 * 1000
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin
const KUNDE = 'c_kunde01'
const F1 = 'f_p_projekt01_termine'
const BASIS_VORHER = config.publicBaseUrl
const plus = (von, ms) => new Date(von.getTime() + ms)
const ms = (wert) => new Date(wert).getTime()
let sink
let log
beforeEach(async () => {
for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'termineConfig', 'customers', 'websiteProjects', '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/
/** 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, { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -TAG).toISOString() })
const vorher = [1, 2, 3].map((nr) => ({ ...funktion(funktionsId(nr)) }))
assert.deepEqual(await laufe(), ergebnis({ geprueft: 3 }))
assert.deepEqual([1, 2, 3].map((nr) => funktion(funktionsId(nr))), vorher)
assert.equal(mails().length, 0)
})
test('Alt-disabled ohne Grund, team und kunde_geloescht: nie angefasst, nie K22', async () => {
const tag = plus(JETZT, -3 * TAG).toISOString()
legeFunktion(1, { state: 'disabled', enabled: false, disabledAt: tag }) // Altbestand ohne Grund
legeFunktion(2, { state: 'disabled', enabled: false, disabledAt: tag, disabledReason: 'team' })
legeFunktion(3, { state: 'disabled', enabled: false, disabledAt: tag, disabledReason: 'kunde_geloescht' })
const vorher = [1, 2, 3].map((nr) => ({ ...funktion(funktionsId(nr)) }))
assert.deepEqual(await laufe(), ergebnis())
assert.deepEqual([1, 2, 3].map((nr) => funktion(funktionsId(nr))), vorher)
assert.equal(mails().length, 0)
assert.equal(abfragenAufAbos(), 0)
})
test('Dokument ohne Kunden-ID wird nie als "kein Abo" gedeutet: bleibt aktiv, fehler 1', async () => {
legeFunktion(1, { customerId: '' })
assert.deepEqual(await laufe(), ergebnis({ fehler: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(abfragenAufAbos(), 0)
})
// ---------------------------------------------------------------- Rennen
test('Rennen: zwischen Vorauswahl und Sperre kommt ein aktives Abo - die frische Pruefung laesst den Terminplaner aktiv', async () => {
legeFunktion(1)
wartung('canceled')
const erg = await mitEinwurf(
liste('subscriptions'),
async () => wartung('active'), // das Abo kommt zurueck, nachdem die Vorauswahl "beendet" gelesen hat
() => laufe(),
)
assert.deepEqual(erg, ergebnis({ geprueft: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(mails().length, 0)
assert.equal(abfragenAufAbos(), 2, 'Vorauswahl und frische Pruefung')
})
test('Rennen: zwischen Liste und Sperre schaltet das Team ab - Grund team und disabledAt bleiben, kein K22', async () => {
legeFunktion(1)
wartung('canceled')
await mitEinwurf(
liste('portalFeatures'),
() => disable(F1, { reason: 'team', adminNote: 'Abgeschaltet von Kenso' }),
() => gleicheAboAb(JETZT),
).then((erg) => assert.deepEqual(erg, ergebnis({ geprueft: 1 })))
const doc = funktion()
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'team')
assert.equal(doc.adminNote, 'Abgeschaltet von Kenso')
assert.ok(Math.abs(ms(doc.disabledAt) - Date.now()) < 60_000, 'disabledAt vom Team, nicht vom Abgleich')
assert.equal(doc.aboEndeMailAt, undefined)
assert.equal(mails().length, 0)
const vorher = { ...doc }
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis(), 'auch am Folgetag nichts')
assert.deepEqual(funktion(), vorher)
})
test('Rennen: zwischen Liste und Sperre wird das Dokument zum Kulanz-Dokument (nicht vom Kauf freigeschaltet) - bleibt aktiv, nichts passiert', async () => {
legeFunktion(1)
wartung('canceled')
const erg = await mitEinwurf(
liste('portalFeatures'),
async () => aw.seed('portalFeatures', { ...funktion(), unlockedByPurchase: false }),
() => laufe(),
)
assert.deepEqual(erg, ergebnis({ geprueft: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(funktion().disabledAt, undefined)
assert.equal(mails().length, 0)
})
test('Sperre: haelt jemand feature-admin:<projectId>, bleibt der Abgleich offen, bis sie frei ist', async () => {
legeFunktion(1)
wartung('canceled')
let fertig = false
let lauf
await withLocks([`feature-admin:${'p_projekt01'}`], async () => {
lauf = gleicheAboAb(JETZT).then((erg) => {
fertig = true
return erg
})
await new Promise((resolve) => setTimeout(resolve, 250))
assert.equal(fertig, false, 'der Abgleich wartet auf die Sperre')
assert.equal(funktion().state, 'active')
assert.equal(mails().length, 0)
})
assert.deepEqual(await lauf, ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(funktion().state, 'disabled')
})
test('Sperre: ein anderes Projekt blockiert nicht', async () => {
legeFunktion(1)
wartung('canceled')
const erg = await withLocks(['feature-admin:p_fremd'], () => gleicheAboAb(JETZT))
assert.deepEqual(erg, ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
})
// ---------------------------------------------------------------- Memo
test('Memo: ein Kunde mit drei Projekten - die Vorauswahl liest das Abo einmal, drei Dokumente abgeschaltet, je Projekt eine K22', async () => {
legeFunktion(1, {}, { projekt: 'Projekt Alpha' })
legeFunktion(2, {}, { projekt: 'Projekt Beta' })
legeFunktion(3, {}, { projekt: 'Projekt Gamma' })
wartung('canceled')
assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, abgeschaltet: 3, mails: 3 }))
for (const nr of [1, 2, 3]) {
assert.equal(funktion(funktionsId(nr)).state, 'disabled')
assert.equal(funktion(funktionsId(nr)).disabledReason, 'abo_ende')
}
assert.deepEqual(mails().map((m) => m.subject).sort(), [
'Dein Terminplaner wurde beendet – Projekt Alpha',
'Dein Terminplaner wurde beendet – Projekt Beta',
'Dein Terminplaner wurde beendet – Projekt Gamma',
])
// eine Abfrage fuer die Vorauswahl (nicht eine je Projekt) plus je abzuschaltendem Dokument die frische Pruefung unter seiner Sperre
assert.equal(abfragenAufAbos(), 1 + 3)
})
test('Memo: mehrere Kunden - je Kunde eine Vorauswahl, die Ausnahmen kosten keine Abfrage', async () => {
aw.seed('customers', { $id: 'c_kunde02', name: 'Hans Zweit', email: 'hans@example.com' })
legeFunktion(1)
legeFunktion(2, {}, { kunde: 'c_kunde02' })
legeFunktion(3, { unlockedByPurchase: false }, { kunde: 'c_kunde03' })
wartung('active')
aw.seed('subscriptions', subscriptionDoc('sub_w2', { ...wartungDoc, customerId: 'c_kunde02', status: 'active' }))
assert.deepEqual(await laufe(), ergebnis({ geprueft: 2, ausgenommen: 1 }))
assert.equal(abfragenAufAbos(), 2)
})
// ---------------------------------------------------------------- K22-Nachlauf
test('K22-Nachlauf: der Transport wirft - disabled, Marker leer, fehler 1; der naechste Tag sendet K22 mit unveraenderten Daten', async () => {
legeFunktion(1)
wartung('canceled')
setMailTransportForTests({
sendMail: async () => {
throw new Error('550 5.1.1 <silke@example.com> Recipient address rejected')
},
})
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
const doc = funktion()
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'abo_ende')
assert.ok(!doc.aboEndeMailAt, 'Marker erst nach Erfolg')
assert.ok(log.error.mock.calls.length >= 1, 'der Fehler steht im Log (ohne Adresse, das prueft afterEach)')
setMailTransportForTests(sink)
const disabledAt = doc.disabledAt
const naechster = plus(JETZT, TAG)
assert.deepEqual(await laufe(naechster), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(mails().length, 1)
assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'Enddatum aus disabledAt, nicht vom Folgetag')
assert.match(mails()[0].text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum bleibt: die Frist wuchs nicht')
assert.equal(funktion().disabledAt, disabledAt)
assert.equal(ms(funktion().aboEndeMailAt), naechster.getTime())
assert.deepEqual(await laufe(plus(naechster, TAG)), ergebnis({ geprueft: 1 }), 'am dritten Tag nichts mehr')
assert.equal(mails().length, 1)
})
test('K22-Nachlauf: Marker-Schreiben scheitert nach dem Versand - fehler 1, die Mail ist raus, der naechste Tag meldet erneut und setzt den Marker', async () => {
legeFunktion(1)
wartung('canceled')
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, after: 1 }) // 1. PATCH = abschalten, 2. = Marker
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1, fehler: 1 }))
assert.equal(funktion().state, 'disabled')
assert.ok(!funktion().aboEndeMailAt)
assert.equal(mails().length, 1)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }), 'lieber doppelt als gar nicht')
assert.equal(ms(funktion().aboEndeMailAt), plus(JETZT, TAG).getTime())
assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1 }))
})
for (const [fall, vorbereitung] of [
['Kunde geloescht', () => deleteDocument('customers', KUNDE)],
['Kunde ohne E-Mail', () => aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' })],
]) {
test(`K22-Nachlauf: ${fall} - kein Wurf, keine Mail, Marker gesetzt, am Folgetag nichts`, async () => {
legeFunktion(1)
wartung('canceled')
await vorbereitung()
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1 }))
assert.equal(funktion().state, 'disabled')
assert.equal(ms(funktion().aboEndeMailAt), JETZT.getTime(), 'kein Wiederholen')
assert.equal(mails().length, 0)
assert.ok(log.warn.mock.calls.length >= 1, 'ein Hinweis im Log, ohne Adresse (das prueft afterEach)')
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 0)
})
}
test('K22-Nachlauf: das Abo laeuft wieder - Schritt 2 uebergeben (E6), keine Mail, Marker bleibt leer', async () => {
legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -2 * TAG).toISOString() })
wartung('active')
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 }))
assert.equal(funktion().state, 'disabled')
assert.ok(!funktion().aboEndeMailAt)
assert.equal(mails().length, 0)
})
test('K22-Nachlauf: mit Marker (aboEndeMailAt) nie eine zweite Mail', async () => {
legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -2 * TAG).toISOString(), aboEndeMailAt: plus(JETZT, -2 * TAG).toISOString() })
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 0)
})
test('K22-Nachlauf: Fehler beim frischen Lesen des Abos - keine Mail, fehler 1, der naechste Tag holt sie nach', async () => {
legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -TAG).toISOString() })
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) // Vorauswahl ok, frische Pruefung scheitert
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, fehler: 1 }))
assert.equal(mails().length, 0)
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }))
assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 04\.10\.2026 beendet wurde/, 'Enddatum = Tag von disabledAt (gestern)')
assert.match(mails()[0].text, /Bis 03\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum = disabledAt (04.10.) + 30 Tage')
})
// ---------------------------------------------------------------- meldeAboEnde (E5/E9 rufen nur ihn)
test('meldeAboEnde: sendet K22 und setzt den Marker (true), mit Marker nichts mehr (true, keine Mail), Fehler im Versand false', async () => {
const id = legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() })
const lage = { laeuft: false, art: 'beendet', endedAt: null }
const spaeter = plus(JETZT, 2 * TAG)
setMailTransportForTests({ sendMail: async () => { throw new Error('Verbindung abgebrochen') } })
assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter }), false)
assert.ok(!funktion(id).aboEndeMailAt)
setMailTransportForTests(sink)
let gesendet = 0
assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter, onGesendet: () => (gesendet += 1) }), true)
assert.equal(gesendet, 1)
assert.equal(mails().length, 1)
assert.equal(ms(funktion(id).aboEndeMailAt), spaeter.getTime())
assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter, onGesendet: () => (gesendet += 1) }), true)
assert.equal(gesendet, 1)
assert.equal(mails().length, 1, 'der Marker verhindert die zweite Mail')
})
test('meldeAboEnde: unbekannter Projektname faellt auf "Dein Projekt" zurueck, ein Lesefehler beim Projekt wiederholt am naechsten Tag', async () => {
const id = legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() })
await deleteDocument('websiteProjects', 'p_projekt01')
const lage = { laeuft: false, art: 'beendet', endedAt: null }
assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), true)
assert.equal(mails()[0].subject, 'Dein Terminplaner wurde beendet – Dein Projekt')
const id2 = legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() })
aw.failNext({ collection: 'websiteProjects', method: 'GET', status: 500 })
assert.equal(await meldeAboEnde(funktion(id2), { lage, now: JETZT }), false)
assert.ok(!funktion(id2).aboEndeMailAt)
assert.equal(mails().length, 1)
})
// ---------------------------------------------------------------- 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, { 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)
})
// ---------------------------------------------------------------- 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: 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')
})