Files
Webklar-Kundenbereich/test/zeitenAboAbgleich.test.js
T
ClaudeandClaude Opus 5.5 c7cd436ec2 Öffnungszeiten: Mails K24–K27 heißen jetzt K27–K30, Feld k24At heißt einschaltMailAt
K24–K26 sind seit dem Webshop-Livegang (main) vergeben; der Mail-Lader sucht die Vorlage über den
Dateianfang, doppelte Nummern hätten die falsche Mail geschickt. Das Feld ist noch nicht in der
Produktion angelegt (zeiten-setup.mjs lief noch nicht), daher jetzt ein sprechender Name.
Spec und Plan behalten die alten Nummern (Hinweis oben im Dokument).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:00:17 +00:00

900 lines
46 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'
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
// Abo-Abgleich von Öffnungszeiten live (Aufgabe 21, Spec 5 "Abo-Abgleich" Schritte 1 bis 4 und Z11): gleicheZeitenAboAb(now) koppelt die im
// Wartungs-Abo enthaltenen Zeiten (unlockedBy 'abo') an das Abo: Abo-Ende erkennen (aboEndeAt, K29), nach 30 Tagen abschalten (K30, Ersatz-Ticket),
// bei Rückkehr des Abos wieder einschalten (K27 "wieder", Neubau nach dem Abschalten), 30 Tage nach dem Abschalten die Daten löschen.
// Aufbau wie termineAboAbgleich.test.js: Appwrite-Nachbau mit Schema, Mails in der Senke, feste Zeit JETZT; Tage zählen nach Berliner Kalendertagen.
// Datumsfelder wie in Appwrite: UTC mit "+00:00", Unlesbares oder '' wäre dort ein 400 (leeren geht nur mit null)
const DATETIMES = {
...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
}
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
},
datetimes: DATETIMES,
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 { activate, adminActivate, ZEITEN_AKTIVIERUNG_FELDER } = await import('../server/services/siteFeatures.js')
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
const { einschaltenZeiten } = await import('../server/services/zeiten/einschalten.js')
const { speichereZeiten, ladeZeiten } = await import('../server/services/zeiten/speicher.js')
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
const { gleicheZeitenAboAb } = await import('../server/services/zeiten/aboAbgleich.js')
const { heute, plusTage, uhrzeit, zuUtc } = await import('../server/services/termine/zeit.js')
after(() => setMailTransportForTests(null))
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (Sommerzeit)
const KUNDE = 'c1'
const ZUVOR = '2026-09-20T10:00:00.000Z'
const BASIS_VORHER = config.publicBaseUrl
const originalRebuild = zeitenDeps.rebuild
const ms = (wert) => new Date(wert).getTime()
/** Der Tag `tage` Kalendertage nach basis, zur selben Berliner Uhrzeit (nie Vielfache von 24 Stunden: am 25.10. endet die Sommerzeit). */
const nach = (tage, basis = JETZT) => zuUtc(plusTage(heute(basis), tage), uhrzeit(basis))
let sink
let log
let rebuilds
let sperrenBeimVersand
/** Ist die Sperre gerade frei? Eine gehaltene Sperre lässt die Probe warten: nach 60 ms gilt sie als gehalten. */
const sperreFrei = (key) => Promise.race([withLocks([key], async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 60))])
/** Alle Dokumente der Tests entfernen (auch mitten im Test, für eine zweite Runde). */
async function raeumeAuf() {
for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects', 'workorders']) {
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
}
resetZeitenCacheForTests()
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' })
}
beforeEach(async () => {
await raeumeAuf()
aw.resetListCalls()
resetZeitenCacheForTests()
config.publicBaseUrl = 'https://project.webklar.com'
sink = mailSink()
// Jede Mail merkt, ob die Sperren des Projekts beim Versand frei waren (die Mails gehen erst nach der Sperre raus)
sperrenBeimVersand = []
const roh = sink.sendMail.bind(sink)
sink.sendMail = async (mail) => {
sperrenBeimVersand.push({ subject: mail.subject, admin: await sperreFrei('feature-admin:p1') })
return roh(mail)
}
setMailTransportForTests(sink)
rebuilds = []
zeitenDeps.rebuild = async (projekt) => {
rebuilds.push({ projektId: projekt.$id, state: aw.docs('portalFeatures').get(`f_${projekt.$id}_zeiten`)?.state, adminFrei: await sperreFrei(`feature-admin:${projekt.$id}`) })
return { queued: true }
}
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}), log: mock.method(console, 'log', () => {}) }
})
afterEach(() => {
// Das Log enthält nie Adresse oder Namen des Kunden
const text = Object.values(log).flatMap((m) => m.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))).join('\n')
mock.timers.reset()
mock.restoreAll()
zeitenDeps.rebuild = originalRebuild
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${nr}`
const funktionsId = (nr = 1) => `f_p${nr}_zeiten`
const funktion = (nr = 1) => aw.docs('portalFeatures').get(funktionsId(nr))
function tage(woche = {}) {
const t = {}
for (let d = 1; d <= 7; d += 1) t[d] = woche[d] ? { modus: 'zeiten', fenster: [woche[d]] } : { modus: 'zu', fenster: [] }
return t
}
const EIN_PLAN = () => ({
land: 'RP',
plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'] }) }],
feiertage: {},
ausnahmen: [],
hinweise: [],
kontakt: { an: false, telefon: '', adresse: '', whatsapp: null },
})
/**
* Funktion "Öffnungszeiten live" von kunde für Projekt nr, wie nach dem Einschalten im Wartungs-Abo: aktiv, Ticket geschlossen, K27 gesendet,
* Zeiten gespeichert (daten, ersatzHash leer). Das Projekt "Praxis Muster" und das Einbau-Ticket legt die Hilfe mit an.
*/
async function legeFunktion(nr = 1, over = {}, { kunde = KUNDE, projekt = 'Praxis Muster', zeiten = true } = {}) {
aw.seed('websiteProjects', { $id: projektId(nr), customerId: kunde, projectName: projekt, subdomain: `praxis${nr}`, repoFullName: `JUSN/praxis${nr}`, previewMode: 'auto', previewDetected: 'static' })
aw.seed('workorders', { $id: `t_${nr}`, woid: `WO-${4710 + nr}`, status: 'Closed', topic: 'Öffnungszeiten live einbauen' })
const doc = aw.seed('portalFeatures', {
$id: funktionsId(nr),
customerId: kunde,
projectId: projektId(nr),
featureKey: 'zeiten',
state: 'active',
enabled: true,
unlockedBy: 'abo',
unlockedByPurchase: false,
siteKey: `abcdefghijklmno${'pqrstuv'[nr]}`,
ticketId: `t_${nr}`,
einbau: 'angestossen',
einschaltMailAt: ZUVOR,
activatedAt: ZUVOR,
...over,
})
if (zeiten) await speichereZeiten({ feature: funktion(nr), eingabe: EIN_PLAN(), version: 0, now: new Date(ZUVOR) })
return doc
}
/** Wartungs-Abo (ein Abo-Dokument je Kunde; ein zweiter Aufruf ersetzt es). */
const wartung = (status, over = {}, kunde = KUNDE) => aw.seed('subscriptions', subscriptionDoc(`sub_${kunde}`, { ...wartungDoc, customerId: kunde, status, ...over }))
const hosting = (status = 'active', kunde = KUNDE) => aw.seed('subscriptions', subscriptionDoc(`sub_${kunde}`, { ...hostingDoc, customerId: kunde, status }))
const abgelaufen = () => wartung('canceled', { endedAt: ZUVOR, canceledAt: ZUVOR })
const laeuft = () => wartung('active')
/** Der Abgleich läuft "zur Zeit now": die Wanduhr steht fest auf now, damit disable() und activate() (new Date()) denselben Zeitpunkt schreiben. */
async function laufe(now = JETZT) {
mock.timers.enable({ apis: ['Date'], now: now.getTime() })
try {
return await gleicheZeitenAboAb(now)
} finally {
mock.timers.reset()
}
}
const ERGEBNIS_LEER = { geprueft: 0, aboEnde: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 }
const ergebnis = (over) => ({ ...ERGEBNIS_LEER, ...over })
const K29 = /^Dein Wartungs-Abo ist beendet/
const K30 = 'Öffnungszeiten live ist abgeschaltet'
const K27 = 'Öffnungszeiten live ist eingeschaltet'
const mails = () => sink.sent
const mailsMit = (betreff) => mails().filter((m) => (betreff instanceof RegExp ? betreff.test(m.subject) : m.subject === betreff))
const tickets = () => [...aw.docs('workorders').values()]
const ersatzTickets = () => tickets().filter((t) => String(t.topic).startsWith('Ersatz-Tabelle aktualisieren'))
const abfragenAufAbos = () => aw.listCalls('subscriptions').length
/** Die Zustände eines Dokuments, die der Abgleich bewegt (für Vergleiche "unverändert"). */
const stand = (nr = 1) => structuredClone({ ...funktion(nr) })
/**
* Nach der ersten Antwort auf eine Anfrage, auf die passt(urlText, methode) zutrifft, läuft 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)
/** Die volle Folge bis zum Abschalten: Tag 0 Abo-Ende erkannt (K29), Tag 30 abgeschaltet (K30). */
async function bisAbgeschaltet() {
await legeFunktion(1)
abgelaufen()
await laufe(JETZT)
await laufe(nach(30))
assert.equal(funktion().state, 'disabled')
}
// ---------------------------------------------------------------- Schritt 1: Abo-Ende erkennen, K29
test('Abo endet: aboEndeAt gesetzt, K29 einmal', async () => {
await legeFunktion(1)
abgelaufen()
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, aboEnde: 1, mails: 1 }))
const doc = funktion()
assert.equal(doc.state, 'active', 'die Funktion läuft bis zur Frist weiter')
assert.equal(doc.enabled, true)
assert.equal(ms(doc.aboEndeAt), JETZT.getTime(), 'aboEndeAt = Zeitpunkt der Erkennung')
assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker der K29 erst nach dem Versand')
assert.equal(doc.unlockedBy, 'abo')
assert.equal(mails().length, 1)
const mail = mails()[0]
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com'])
assert.match(mail.subject, K29)
assert.match(mail.text, /Silke Müller/)
assert.match(mail.text, /Dein Wartungs-Abo ist beendet\./, 'Variante beendet')
assert.match(mail.text, /Läuft weiter bis: 04\.11\.2026/, 'Frist = Tag der Erkennung + 30 Tage')
assert.match(mail.text, /einmalig 30 €/)
assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#zeiten/, 'Behalten-Link zum Editor')
assert.ok(mail.bcc?.length, 'Kopie ans Team wie bei jeder Kundenmail')
assert.equal(rebuilds.length, 0)
// am selben Tag und am Folgetag: nichts mehr, keine zweite Mail, aboEndeAt bleibt (die Frist wird nie verschoben)
const vorher = stand()
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1 }))
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1 }))
assert.deepEqual(stand(), vorher)
assert.equal(mails().length, 1)
})
test('Läuft das Abo, geschieht nichts: kein Schreiben, keine Mail', async () => {
await legeFunktion(1)
laeuft()
const vorher = stand()
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 }))
assert.deepEqual(stand(), vorher)
assert.equal(mails().length, 0)
})
test('K29-Variante bei Wechsel zu Hosting und bei widerrufenem Wechsel', async () => {
const varianten = [
['Hosting-Abo', () => hosting('active'), /Dein Abo ist ein Hosting-Abo\./, /Du hast den Wechsel in das Wartungs-Abo widerrufen/],
[
'Wechsel widerrufen (der Spiegel sagt noch "Wartung läuft")',
() => {
wartung('active')
aw.seed('contractEvents', { $id: 'ev_1', customerId: KUNDE, subscriptionId: `sub_${KUNDE}`, type: 'wechsel', productKey: 'wartung', details: '{}', status: 'widerrufen' })
},
/Du hast den Wechsel in das Wartungs-Abo widerrufen/,
/Dein Wartungs-Abo ist beendet\./,
],
]
for (const [name, abo, passt, passtNicht] of varianten) {
await raeumeAuf()
sink.sent.length = 0
await legeFunktion(1)
abo()
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, aboEnde: 1, mails: 1 }), name)
assert.match(mails()[0].subject, K29, name)
assert.match(mails()[0].text, passt, name)
assert.doesNotMatch(mails()[0].text, passtNicht, name)
assert.equal(funktion().state, 'active', `${name}: die Frist läuft`)
}
})
test('K29 geht nur an die Adresse des Kundenkontos; ohne Adresse setzt der Lauf den Marker ohne Fehler', async () => {
await legeFunktion(1)
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' })
abgelaufen()
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, aboEnde: 1 }))
assert.equal(mails().length, 0)
assert.equal(ms(funktion().aboEndeMailAt), JETZT.getTime(), 'es gibt nichts zu senden, also auch nichts zu wiederholen')
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1 }))
})
test('K29 scheitert (Mail): aboEndeAt steht, der Marker fehlt, der nächste Lauf sendet nach (einmal)', async () => {
await legeFunktion(1)
abgelaufen()
const roh = sink.sendMail
sink.sendMail = async () => {
throw new Error('SMTP down für silke@example.com')
}
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, aboEnde: 1, fehler: 1 }))
assert.equal(ms(funktion().aboEndeAt), JETZT.getTime())
assert.ok(!funktion().aboEndeMailAt, 'ohne Versand kein Marker')
assert.equal(mails().length, 0)
sink.sendMail = roh
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(mailsMit(K29).length, 1)
assert.equal(ms(funktion().aboEndeAt), JETZT.getTime(), 'aboEndeAt bleibt: die Frist wächst nicht')
assert.match(mails()[0].text, /Läuft weiter bis: 04\.11\.2026/, 'dieselbe Frist wie beim ersten Versuch')
assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 1)
})
// ---------------------------------------------------------------- Schritt 2: nach 30 Tagen abschalten, K30, Ersatz-Ticket
test('Tag 29: noch aktiv; Tag 30: disabled abo_ende, K30, Ersatz-Ticket', async () => {
await legeFunktion(1)
abgelaufen()
await laufe(JETZT)
mails().length = 0
const tag29 = await laufe(nach(29))
assert.deepEqual(tag29, ergebnis({ geprueft: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(mails().length, 0)
const tag30 = nach(30)
assert.equal(heute(tag30), '2026-11-04')
assert.deepEqual(await laufe(tag30), 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), tag30.getTime())
assert.equal(ms(doc.aboEndeAt), JETZT.getTime(), 'aboEndeAt bleibt als Spur stehen')
assert.equal(ms(doc.aboEndeMailAt), tag30.getTime(), 'Marker gilt jetzt der K30')
assert.match(doc.adminNote, /System: Abgeschaltet: Wartungs-Abo beendet/)
// K30: Datum der Löschung = Abschalten + 30 Tage
assert.equal(mailsMit(K30).length, 1)
const mail = mailsMit(K30)[0]
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com'])
assert.match(mail.text, /Eingaben gespeichert bis: 04\.12\.2026/)
assert.match(mail.text, /30 €/)
assert.ok(mail.bcc?.length)
// Ersatz-Ticket (R23: ohne gemerkten Hash gilt der Stand als unbekannt, Zeiten sind gespeichert)
assert.equal(ersatzTickets().length, 1)
assert.match(ersatzTickets()[0].topic, /Praxis Muster/)
assert.ok((await ladeZeiten(funktionsId(1))).ersatzHash, 'der Hash wurde nach dem Ticket gemerkt')
assert.equal(rebuilds.length, 0)
// Folgetag: nichts mehr (kein zweites Ticket, keine zweite Mail, disabledAt bleibt)
const vorher = stand()
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1 }))
assert.deepEqual(stand(), vorher)
assert.equal(ersatzTickets().length, 1)
assert.equal(mails().length, 1)
})
test('Frist nach Berliner Kalendertagen, nicht nach 24-Stunden-Schritten (Ende der Sommerzeit 25.10.)', async () => {
// Abo-Ende erkannt am 24.10. um 23:30 Berliner Zeit (21:30 UTC); Frist = 23.11.
const erkannt = new Date('2026-10-24T21:30:00.000Z')
await legeFunktion(1, { aboEndeAt: erkannt.toISOString(), aboEndeMailAt: erkannt.toISOString() })
abgelaufen()
// 22.11., 23:59 Berliner Zeit: noch ein Tag
assert.deepEqual(await laufe(new Date('2026-11-22T22:59:00.000Z')), ergebnis({ geprueft: 1 }))
assert.equal(funktion().state, 'active')
// 23.11., 00:01 Berliner Zeit: der Tag der Frist ist da, auch vor 23:30 (24 x 30 Stunden später wäre erst 22:30 am 23.11.)
assert.deepEqual(await laufe(new Date('2026-11-22T23:01:00.000Z')), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(funktion().state, 'disabled')
assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 23\.12\.2026/)
})
test('Tag 30 mit zurückgekehrtem Abo: nicht abgeschaltet, sondern die Rückkehr', async () => {
await legeFunktion(1)
abgelaufen()
await laufe(JETZT)
laeuft()
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(ersatzTickets().length, 0)
assert.equal(mailsMit(K30).length, 0)
})
test('K30 scheitert (Mail): abgeschaltet bleibt, der Marker fehlt, der nächste Lauf sendet nach; das Ersatz-Ticket entsteht nur einmal', async () => {
await legeFunktion(1)
abgelaufen()
await laufe(JETZT)
const roh = sink.sendMail
sink.sendMail = async () => {
throw new Error('SMTP down')
}
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
assert.equal(funktion().state, 'disabled')
assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'der Marker der K29 gilt nicht für die K30')
assert.equal(ersatzTickets().length, 1)
sink.sendMail = roh
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(mailsMit(K30).length, 1)
assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung, nicht von heute')
assert.equal(ersatzTickets().length, 1)
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 }))
})
test('Ersatz-Ticket scheitert: abgeschaltet bleibt, fehler zählt, K30 und Marker fehlen; der nächste Lauf legt das Ticket an und sendet K30 genau einmal', async () => {
await legeFunktion(1)
abgelaufen()
await laufe(JETZT)
mails().length = 0
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
assert.equal(funktion().state, 'disabled')
assert.equal(ersatzTickets().length, 0)
assert.equal(mailsMit(K30).length, 0, 'K30 erst nach dem Ersatz-Ticket: so geht sie nie ein zweites Mal raus')
assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'kein K30-Marker')
// der nächste Lauf wiederholt beides (Ticket idempotent über den Hash) und sendet K30 einmal
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(ersatzTickets().length, 1)
assert.equal(mailsMit(K30).length, 1)
assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung')
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker gilt jetzt der K30')
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 }))
assert.equal(ersatzTickets().length, 1)
assert.equal(mailsMit(K30).length, 1)
})
test('Ersatz-Ticket scheitert mehrere Läufe: kein K30, jeder Lauf zählt fehler; sobald es klappt, genau eine K30', async () => {
await legeFunktion(1)
abgelaufen()
await laufe(JETZT)
mails().length = 0
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
await laufe(nach(30))
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, fehler: 1 }))
assert.equal(mailsMit(K30).length, 0)
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(mailsMit(K30).length, 1)
assert.equal(ersatzTickets().length, 1)
})
test('Abschalten gelingt, die Folge scheitert (Marker der K29 steht noch, älter als disabledAt): der nächste Lauf holt K30 und das Ersatz-Ticket nach, einmal', async () => {
// So steht das Dokument, wenn nach disable() nichts mehr geschrieben wurde: disabled/abo_ende, der Marker ist der der K29
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR, aboEndeMailAt: ZUVOR })
abgelaufen()
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(mailsMit(K30).length, 1)
assert.equal(ersatzTickets().length, 1)
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt))
assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 1)
assert.equal(ersatzTickets().length, 1)
})
test('Wanduhr nach dem Laufstart (disabledAt später als now): der K30-Marker liegt nie davor, K30 geht nicht täglich raus', async () => {
await legeFunktion(1)
abgelaufen()
await laufe(JETZT)
mails().length = 0
const tag30 = nach(30)
mock.timers.enable({ apis: ['Date'], now: tag30.getTime() + 5000 }) // disable() schreibt "jetzt" 5 s nach dem now des Laufs
try {
assert.deepEqual(await gleicheZeitenAboAb(tag30), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
} finally {
mock.timers.reset()
}
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker nicht vor disabledAt')
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1 }))
assert.equal(mailsMit(K30).length, 1)
})
// ---------------------------------------------------------------- Schritt 3: Rückkehr des Abos
test('Rückkehr an Tag 45: zurück auf active, Neubau, K27 wieder', async () => {
await bisAbgeschaltet()
mails().length = 0
laeuft()
const tag45 = nach(45)
assert.deepEqual(await laufe(tag45), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
const doc = funktion()
assert.equal(doc.state, 'active', 'das Ticket war geschlossen (activatedAt)')
assert.equal(doc.enabled, true)
assert.deepEqual([doc.disabledAt ?? null, doc.disabledReason ?? null, doc.aboEndeAt ?? null, doc.aboEndeMailAt ?? null, doc.behaltenSessionId ?? ''], [null, null, null, null, ''])
assert.equal(doc.unlockedBy, 'abo')
assert.equal(doc.einbau, 'angestossen', 'Ergebnis des neuen Neubaus')
assert.match(doc.adminNote, /Wieder eingeschaltet: Wartungs-Abo läuft wieder/)
// Neubau nach dem Schreiben des Zustands und ausserhalb der Sperre
assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'active', adminFrei: true }])
// K27 mit dem Block wieder und dem Neubau-Text
assert.equal(mailsMit(K27).length, 1)
const mail = mailsMit(K27)[0]
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com'])
assert.match(mail.text, /Dein Wartungs-Abo läuft wieder/)
assert.match(mail.text, /nach dem nächsten Veröffentlichen/)
assert.doesNotMatch(mail.text, /\(Ticket #/, 'kein neues Ticket, keine Ticketnummer')
// die Daten sind da (nie angefasst) und danach ist wieder Ruhe
assert.ok((await ladeZeiten(funktionsId(1))).daten)
assert.deepEqual(await laufe(nach(46)), ergebnis({ geprueft: 1 }))
assert.equal(rebuilds.length, 1)
})
test('Rückkehr aus disabled: ein nie freigeschaltetes Dokument (kein activatedAt) kommt auf setup zurück; einbau manuell sagt K27 "das Team baut ein"', async () => {
await legeFunktion(1, { state: 'setup', enabled: false, activatedAt: null })
abgelaufen()
await laufe(JETZT)
await laufe(nach(30))
assert.equal(funktion().state, 'disabled')
mails().length = 0
zeitenDeps.rebuild = async () => ({ queued: false })
laeuft()
assert.deepEqual(await laufe(nach(40)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
const doc = funktion()
assert.equal(doc.state, 'setup')
assert.equal(doc.enabled, false)
assert.equal(doc.einbau, 'manuell')
assert.equal(doc.ticketId, 't_1', 'das Einbau-Ticket bleibt, der 5-Minuten-Job gibt frei, sobald es zu ist')
assert.match(mails()[0].text, /baut unser Team/)
})
test('Rückkehr vor dem Abschalten (Tag 10): aboEndeAt leer, Zustand bleibt, kein Neubau, K27 wieder ohne "Wie geht es weiter"', async () => {
for (const [name, over] of [
['aktiv', {}],
['in Einrichtung', { state: 'setup', enabled: false, activatedAt: null }],
]) {
await raeumeAuf()
mails().length = 0
rebuilds.length = 0
await legeFunktion(1, over, { zeiten: false })
abgelaufen()
await laufe(JETZT)
// eine offene Behalten-Zahlung (Sitzung) ist mit der Rückkehr hinfällig
aw.seed('portalFeatures', { ...funktion(1), behaltenSessionId: 'cs_test_offen' })
assert.equal(funktion().behaltenSessionId, 'cs_test_offen', name)
mails().length = 0
laeuft()
assert.deepEqual(await laufe(nach(10)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }), name)
const doc = funktion()
assert.equal(doc.state, over.state ?? 'active', `${name}: Zustand bleibt`)
assert.deepEqual([doc.aboEndeAt ?? null, doc.aboEndeMailAt ?? null, doc.behaltenSessionId ?? ''], [null, null, ''], `${name}: aboEndeAt, Marker und Sitzung geleert`)
assert.equal(doc.einbau, 'angestossen', name)
assert.equal(rebuilds.length, 0, `${name}: die Leisten waren nie weg, kein Neubau`)
assert.equal(mailsMit(K27).length, 1, name)
const text = mailsMit(K27)[0].text
assert.match(text, /Dein Wartungs-Abo läuft wieder/, name)
assert.doesNotMatch(text, /Wie geht es weiter/, `${name}: beide Blöcke aus, der Kasten entfällt`)
assert.doesNotMatch(text, /nach dem nächsten Veröffentlichen|baut unser Team|Ohne gespeicherte Zeiten/, name)
}
})
test('Rückkehr ohne vorherige K29 (kein Marker): die Funktion wird bereinigt, der Kunde bekommt keine Mail über etwas, das er nie erfuhr', async () => {
await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), behaltenSessionId: 'cs_test_offen' })
laeuft()
assert.deepEqual(await laufe(nach(3)), ergebnis({ geprueft: 1, wiederEin: 1 }))
assert.equal(mails().length, 0)
assert.deepEqual([funktion().aboEndeAt ?? null, funktion().behaltenSessionId ?? ''], [null, ''])
})
test('Rückkehr: das Abo kommt zwischen Vorauswahl und Sperre zurück ⇒ der frisch gelesene Stand entscheidet, nicht abgeschaltet', async () => {
await legeFunktion(1)
abgelaufen()
await laufe(JETZT)
mails().length = 0
const erg = await mitEinwurf(liste('subscriptions'), async () => laeuft(), () => laufe(nach(30)))
assert.deepEqual(erg, ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
assert.equal(funktion().state, 'active')
assert.equal(mailsMit(K30).length, 0)
assert.equal(mailsMit(K27).length, 1)
assert.equal(ersatzTickets().length, 0)
})
test('Rückkehr am Tag der Löschung, Daten noch da: die Rückkehr gewinnt vor der Löschung', async () => {
await bisAbgeschaltet()
laeuft()
assert.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
assert.equal(funktion().state, 'active')
assert.ok((await ladeZeiten(funktionsId(1))).daten, 'Daten erhalten')
})
test('Löschung fällig, aber die Daten sind schon weg (Lauf nach dem Löschen unterbrochen) und das Abo läuft: nicht wieder einschalten, sondern abschliessen', async () => {
await bisAbgeschaltet()
await deleteDocument('oeffnungszeiten', funktionsId(1))
laeuft()
assert.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, geloescht: 1 }))
assert.equal(funktion().state, 'off')
assert.equal(rebuilds.length, 0)
assert.equal(mailsMit(K27).length, 0)
})
// ---------------------------------------------------------------- Schritt 4: Löschung
test('Tag 60 nach dem Abo-Ende (30 Tage nach der Abschaltung): Daten gelöscht, state off', async () => {
await bisAbgeschaltet()
const feature = funktion()
aw.seed('zeitenErinnerungen', { $id: 'm1', featureId: feature.$id, datum: '2026-11-10', createdAt: JETZT.toISOString() })
aw.seed('zeitenErinnerungen', { $id: 'm2', featureId: 'f_andere_zeiten', datum: '2026-11-10', createdAt: JETZT.toISOString() })
mails().length = 0
const tag59 = nach(59)
assert.equal(heute(tag59), '2026-12-03')
assert.deepEqual(await laufe(tag59), ergebnis({ geprueft: 1 }), 'Tag 59 nach dem Abo-Ende: noch nichts')
assert.ok(await ladeZeiten(feature.$id))
const tag60 = nach(60)
assert.equal(heute(tag60), '2026-12-04', '30 Tage nach der Abschaltung am 04.11.')
assert.deepEqual(await laufe(tag60), ergebnis({ geprueft: 1, geloescht: 1 }))
const doc = funktion()
assert.equal(doc.state, 'off')
assert.equal(doc.enabled, false)
assert.deepEqual(
[doc.ticketId ?? '', doc.einbau ?? '', doc.einschaltMailAt ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? '', doc.activatedAt ?? null],
['', '', null, null, '', null],
'ticketId, einbau, einschaltMailAt, aboEndeAt, behaltenSessionId, activatedAt geleert (sonst käme ein späterer Lauf zu früh auf active)'
)
assert.match(doc.adminNote, /Öffnungszeiten gelöscht, 30 Tage nach dem Abschalten/)
assert.equal(await ladeZeiten(feature.$id), null, 'das Zeiten-Dokument (samt ersatzHash) ist gelöscht')
assert.equal(aw.docs('oeffnungszeiten').size, 0)
assert.equal(aw.docs('zeitenErinnerungen').has('m1'), false, 'Erinnerungsmerker der Funktion gelöscht')
assert.equal(aw.docs('zeitenErinnerungen').has('m2'), true, 'fremde Merker bleiben')
assert.equal(mails().length, 0, 'die K30 hat die Löschung angekündigt, es gibt keine weitere Mail')
// aus ist aus: der nächste Lauf liest das Dokument nicht mehr
assert.deepEqual(await laufe(nach(61)), ergebnis())
})
test('nach der Löschung: ticketId, einbau, einschaltMailAt, ersatzHash leer; Neubestellung legt ein neues Ticket an', async () => {
await bisAbgeschaltet()
await laufe(nach(60))
assert.equal(funktion().state, 'off')
assert.equal(await ladeZeiten(funktionsId(1)), null, 'ersatzHash lebte im gelöschten Dokument')
const ticketsVorher = tickets().length
mails().length = 0
rebuilds.length = 0
// Das Abo läuft wieder, der Kunde schaltet neu ein (Gratisweg): neues Ticket, Neubau, K27
laeuft()
const { doc, changed } = await einschaltenZeiten({ customerId: KUNDE, projectId: 'p1', by: 'kunde', now: nach(61) })
assert.equal(changed, true)
assert.equal(doc.state, 'setup')
assert.ok(doc.ticketId && doc.ticketId !== 't_1', 'ein neues Einbau-Ticket')
assert.equal(tickets().length, ticketsVorher + 1)
assert.equal(doc.einbau, 'angestossen')
assert.ok(doc.einschaltMailAt, 'K27 neu gesendet')
assert.equal(mailsMit(K27).length, 1)
assert.equal(rebuilds.length, 1)
})
test('Löschung scheitert (Zeiten-Dokument nicht löschbar): state bleibt disabled, fehler zählt, der nächste Lauf versucht es wieder', async () => {
await bisAbgeschaltet()
aw.failNext({ collection: 'oeffnungszeiten', method: 'DELETE', status: 500, message: 'Löschen kaputt' })
assert.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, fehler: 1 }))
assert.equal(funktion().state, 'disabled')
assert.deepEqual(await laufe(nach(61)), ergebnis({ geprueft: 1, geloescht: 1 }))
assert.equal(funktion().state, 'off')
})
test('Abo-Lesefehler vor der Löschung: es wird nie gelöscht', async () => {
await bisAbgeschaltet()
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
assert.deepEqual(await laufe(nach(60)), ergebnis({ fehler: 1 }))
assert.equal(funktion().state, 'disabled')
assert.ok(await ladeZeiten(funktionsId(1)))
})
// ---------------------------------------------------------------- Nur unlockedBy abo
test('gekaufte und Team-Freischaltungen bleiben unberührt (das Abo wird nicht einmal gelesen)', async () => {
await legeFunktion(1, { unlockedBy: 'kauf', unlockedByPurchase: true, purchaseStatus: 'paid' })
await legeFunktion(2, { unlockedBy: 'team' })
await legeFunktion(3, { unlockedBy: undefined, unlockedByPurchase: true, purchaseStatus: 'paid' }) // Altbestand ohne unlockedBy: Kauf
await legeFunktion(4, { unlockedBy: undefined }) // Altbestand ohne unlockedBy und ohne Kauf: Team
await legeFunktion(5, { state: 'disabled', enabled: false, unlockedBy: 'kauf', unlockedByPurchase: true, disabledReason: 'abo_ende', disabledAt: '2026-08-01T08:00:00.000Z' })
await legeFunktion(6, { state: 'setup', enabled: false, unlockedBy: 'team', aboEndeAt: ZUVOR })
abgelaufen()
const vorher = [1, 2, 3, 4, 5, 6].map((nr) => stand(nr))
aw.resetListCalls()
for (const now of [JETZT, nach(40), nach(100)]) assert.deepEqual(await laufe(now), ergebnis())
assert.deepEqual([1, 2, 3, 4, 5, 6].map((nr) => stand(nr)), vorher)
assert.equal(abfragenAufAbos(), 0, 'kein gekoppeltes Dokument: keine Abo-Abfrage')
assert.equal(mails().length, 0)
assert.equal(ersatzTickets().length, 0)
assert.equal(rebuilds.length, 0)
assert.equal(aw.docs('oeffnungszeiten').size, 6)
})
test('News und Terminplaner (andere featureKey) und Entwürfe (off) bleiben unberührt', async () => {
await legeFunktion(1, { state: 'off', enabled: false })
aw.seed('portalFeatures', { $id: 'f_p9_termine', customerId: KUNDE, projectId: 'p9', featureKey: 'termine', state: 'active', enabled: true, unlockedBy: 'abo', unlockedByPurchase: false, siteKey: 'abcdefghijklmnoz' })
aw.seed('portalFeatures', { $id: 'f_p9_news', customerId: KUNDE, projectId: 'p9', featureKey: 'news', state: 'active', enabled: true, unlockedBy: 'abo', unlockedByPurchase: false, siteKey: 'abcdefghijklmnoy' })
abgelaufen()
const vorher = [funktion(), aw.docs('portalFeatures').get('f_p9_termine'), aw.docs('portalFeatures').get('f_p9_news')].map((d) => structuredClone({ ...d }))
assert.deepEqual(await laufe(nach(40)), ergebnis())
assert.deepEqual([funktion(), aw.docs('portalFeatures').get('f_p9_termine'), aw.docs('portalFeatures').get('f_p9_news')].map((d) => structuredClone({ ...d })), vorher)
assert.equal(abfragenAufAbos(), 0)
})
test('Grund team (und alle anderen Gründe ausser abo_ende) wird nie gelöscht', async () => {
const alt = '2026-06-01T08:00:00.000Z'
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: alt, aboEndeAt: alt })
await legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: alt })
await legeFunktion(3, { state: 'disabled', enabled: false, disabledReason: 'kunde_geloescht', disabledAt: alt })
await legeFunktion(4, { state: 'disabled', enabled: false, disabledAt: alt }) // Altbestand ohne Grund
abgelaufen()
const vorher = [1, 2, 3, 4].map((nr) => stand(nr))
assert.deepEqual(await laufe(nach(200)), ergebnis())
assert.deepEqual([1, 2, 3, 4].map((nr) => stand(nr)), vorher)
assert.equal(aw.docs('oeffnungszeiten').size, 4, 'alle Daten bleiben')
// auch bei zurückgekehrtem Abo bleibt die Team-Abschaltung abgeschaltet
laeuft()
assert.deepEqual(await laufe(nach(201)), ergebnis())
assert.equal(funktion(1).state, 'disabled')
assert.equal(mails().length, 0)
})
// ---------------------------------------------------------------- Fehler und Fail-closed
test('Abo-Lesefehler: überspringen, nie abschalten (der andere Kunde ist trotzdem dran, der nächste Lauf holt es nach)', async () => {
// c1: Frist verstrichen, würde abgeschaltet; c2: Löschtag erreicht, würde gelöscht
await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() })
await legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() }, { kunde: 'c2' })
aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' })
wartung('canceled', { endedAt: ZUVOR })
wartung('canceled', { endedAt: ZUVOR }, 'c2')
const vorher = [stand(1), stand(2)]
// das Lesen des Abos von c1 scheitert (Vorauswahl, der erste Abo-Abruf): nichts wird abgeschaltet und nichts gesendet
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Appwrite down' })
assert.deepEqual(await laufe(nach(200)), ergebnis({ geprueft: 1, geloescht: 1, fehler: 1 }))
assert.deepEqual(stand(1), vorher[0], 'c1 unverändert')
assert.equal(funktion(2).state, 'off', 'c2 war lesbar und fällig')
assert.equal(mails().length, 0)
assert.equal(ersatzTickets().length, 0)
// das Lesen des Abos von c2 scheitert, c1 ist lesbar: c2 bleibt, wie es ist (nie löschen, wenn unklar ist, ob das Abo zurück ist)
await raeumeAuf()
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() })
wartung('canceled', { endedAt: ZUVOR })
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Appwrite down' })
assert.deepEqual(await laufe(nach(200)), ergebnis({ fehler: 1 }))
assert.equal(funktion(1).state, 'disabled')
assert.ok(await ladeZeiten(funktionsId(1)), 'Daten erhalten')
// der nächste Lauf mit lesbarem Abo holt es nach
assert.deepEqual(await laufe(nach(201)), ergebnis({ geprueft: 1, geloescht: 1 }))
assert.equal(funktion(1).state, 'off')
})
test('Abo-Lesefehler unter der Sperre (zweites Lesen): nichts wird geschrieben', async () => {
await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() })
abgelaufen()
const vorher = stand()
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) // Vorauswahl ok, frisches Lesen scheitert
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, fehler: 1 }))
assert.deepEqual(stand(), vorher)
assert.equal(mails().length, 0)
assert.equal(ersatzTickets().length, 0)
})
test('Liste nicht lesbar: der Abgleich entfällt, kein Wurf', async () => {
await legeFunktion(1)
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Appwrite down' })
assert.deepEqual(await laufe(), ergebnis({ fehler: 1 }))
assert.equal(mails().length, 0)
})
test('ein unlesbares Datum bei einem Dokument stoppt die anderen nicht (je Dokument gefangen)', async () => {
await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() })
await legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }, { kunde: 'c2' })
await legeFunktion(3, {}, { kunde: 'c3' })
aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' })
aw.seed('customers', { $id: 'c3', name: 'Eva Schmidt', email: 'eva@example.com' })
for (const kunde of [KUNDE, 'c2', 'c3']) wartung('canceled', { endedAt: ZUVOR }, kunde)
// Datumsfelder, die Appwrite nie liefern würde: der Nachbau prüft sie hier nicht
const feldNamen = DATETIMES.portalFeatures
DATETIMES.portalFeatures = feldNamen.filter((f) => f !== 'aboEndeAt' && f !== 'disabledAt')
try {
aw.seed('portalFeatures', { ...funktion(1), aboEndeAt: 'kein-datum' })
aw.seed('portalFeatures', { ...funktion(2), disabledAt: 'kein-datum' })
} finally {
DATETIMES.portalFeatures = feldNamen
}
const vorher = [stand(1), stand(2)]
const erg = await laufe(nach(30))
assert.deepEqual(erg, ergebnis({ geprueft: 3, aboEnde: 1, mails: 1, fehler: 2 }), 'das dritte Dokument ist trotzdem dran: Abo-Ende erkannt, K29')
assert.deepEqual([stand(1), stand(2)], vorher, 'die kaputten Dokumente bleiben, wie sie sind')
assert.deepEqual(mails().map((m) => m.to[0].address), ['eva@example.com'])
assert.equal(ersatzTickets().length, 0)
assert.equal(aw.docs('oeffnungszeiten').size, 3, 'nichts gelöscht')
})
test('Kunde nicht lesbar beim Versand: fehler, das nächste Dokument ist trotzdem dran, der nächste Lauf sendet die K29 nach', async () => {
await legeFunktion(1)
await legeFunktion(2, {}, { kunde: 'c2' })
aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' })
for (const kunde of [KUNDE, 'c2']) wartung('canceled', { endedAt: ZUVOR }, kunde)
aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Appwrite down' })
assert.deepEqual(await laufe(), ergebnis({ geprueft: 2, aboEnde: 2, mails: 1, fehler: 1 }))
assert.equal(ms(funktion(1).aboEndeAt), JETZT.getTime(), 'das Abo-Ende steht, die Frist läuft')
assert.ok(!funktion(1).aboEndeMailAt)
assert.deepEqual(mails().map((m) => m.to[0].address), ['karl@example.com'])
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 2, mails: 1 }))
assert.deepEqual(mails().map((m) => m.to[0].address), ['karl@example.com', 'silke@example.com'])
assert.match(mails()[1].text, /Läuft weiter bis: 04\.11\.2026/, 'Frist vom ersten Lauf, nicht von heute')
})
test('Mails und Neubau gehen ohne gehaltene Sperre feature-admin raus', async () => {
await legeFunktion(1)
abgelaufen()
await laufe(JETZT) // K29
await laufe(nach(30)) // K30
laeuft()
await laufe(nach(45)) // Neubau und K27
assert.deepEqual(
sperrenBeimVersand.map((s) => [s.subject === K30 ? 'K30' : s.subject === K27 ? 'K27' : 'K29', s.admin]),
[['K29', true], ['K30', true], ['K27', true]]
)
assert.equal(rebuilds[0].adminFrei, true)
})
// ---------------------------------------------------------------- Zähler
test('Abo-Abfragen: die Vorauswahl liest je Kunde einmal, dazu das frische Lesen je bearbeitetem Dokument; mehrere Projekte eines Kunden werden einzeln bearbeitet', async () => {
await legeFunktion(1)
await legeFunktion(2)
await legeFunktion(3, {}, { kunde: 'c2' })
aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' })
wartung('canceled', { endedAt: ZUVOR }, KUNDE)
wartung('active', {}, 'c2')
aw.resetListCalls()
assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, aboEnde: 2, mails: 2 }))
assert.equal(abfragenAufAbos(), 2 + 2, 'zwei Kunden in der Vorauswahl; frisch nur für die zwei Dokumente von c1 (das von c2 braucht nichts)')
assert.equal(mailsMit(K29).length, 2)
assert.deepEqual(mails().map((m) => m.to[0].address), ['silke@example.com', 'silke@example.com'])
assert.ok(funktion(1).aboEndeAt && funktion(2).aboEndeAt)
assert.ok(!funktion(3).aboEndeAt)
})
test('now muss ein gültiges Datum sein (Programmierfehler, wie bei den Terminplaner-Jobs)', async () => {
await assert.rejects(() => gleicheZeitenAboAb(undefined), TypeError)
await assert.rejects(() => gleicheZeitenAboAb(new Date('x')), TypeError)
})
// ---------------------------------------------------------------- R25: activate() räumt das Abo-Ende ab
test('activate() leert aboEndeAt und behaltenSessionId (zusammen mit Grund, Marker und disabledAt); zahlungMeldung bleibt', async () => {
await legeFunktion(1, {
state: 'disabled',
enabled: false,
disabledAt: ZUVOR,
disabledReason: 'abo_ende',
aboEndeMailAt: ZUVOR,
aboEndeAt: ZUVOR,
behaltenSessionId: 'cs_test_offen',
zahlungMeldung: 'abo_ende',
})
// der Zeiten-Pfad gibt die Zeiten-Felder als extra mit (activate() schreibt sie nie von selbst, News und Terminplaner teilen die Collection)
const doc = await activate(funktionsId(1), { adminNote: 'Test', extra: ZEITEN_AKTIVIERUNG_FELDER })
assert.deepEqual([doc.state, doc.enabled], ['active', true])
assert.deepEqual([doc.disabledAt ?? null, doc.disabledReason ?? null, doc.aboEndeMailAt ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? ''], [null, null, null, null, ''])
assert.equal(doc.zahlungMeldung, 'abo_ende')
// Folge: ein abgeschalteter Lauf des Abgleichs sieht ein aktives Dokument ohne Abo-Ende
laeuft()
assert.deepEqual(await laufe(nach(5)), ergebnis({ geprueft: 1 }))
})
test('activate() ohne extra lässt aboEndeAt und behaltenSessionId stehen; adminActivate() der Zeiten gibt sie als extra mit', async () => {
const offen = { state: 'disabled', enabled: false, disabledAt: ZUVOR, disabledReason: 'abo_ende', aboEndeAt: ZUVOR, behaltenSessionId: 'cs_test_offen' }
await legeFunktion(1, offen)
const ohne = await activate(funktionsId(1), { adminNote: 'Test' })
assert.equal(ohne.state, 'active')
assert.deepEqual([ms(ohne.aboEndeAt), ohne.behaltenSessionId], [ms(ZUVOR), 'cs_test_offen'])
await aw.seed('portalFeatures', { ...funktion(1), ...offen })
const team = await adminActivate(KUNDE, projektId(1), 'zeiten', { by: 'Kenso' })
assert.equal(team.state, 'active')
assert.deepEqual([team.aboEndeAt ?? null, team.behaltenSessionId ?? ''], [null, ''])
})
test('abgeschaltet mit abo_ende, aber ohne Marker (Lauf nach dem Abschalten unterbrochen): der nächste Lauf holt K30 und das Ersatz-Ticket nach', async () => {
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR })
abgelaufen()
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(mailsMit(K30).length, 1)
assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 04\.11\.2026/, 'Löschdatum = disabledAt + 30 Tage')
assert.equal(ersatzTickets().length, 1)
assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 1)
})