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>
900 lines
46 KiB
JavaScript
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)
|
|
})
|