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>
936 lines
47 KiB
JavaScript
936 lines
47 KiB
JavaScript
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')
|
||
})
|