import { test, after, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' // Freischaltung ueber das Register (Task B11): activateClosedTickets liest die Schluessel mit ticketFreischaltung aus // config.features und fragt je Dokument unter der Sperre (nach dem frischen Lesen) den Haken freischaltbar. Termine: // Einstellung vorhanden und laufendes Wartungs-Abo (Spec 4.5). Blockiert: das Dokument bleibt 'setup', EIN adminNote-Eintrag, // keine Mail. websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), termineConfig: schemaKeys('termineConfig'), contractEvents: schemaKeys('contractEvents'), }, }) after(() => aw.close()) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { withLocks } = await import('../server/services/keyedLock.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { mailSink } = await import('./support/mailSink.js') const reg = await import('../server/services/featureRegistry.js') const sf = await import('../server/services/siteFeatures.js') const mails = mailSink() setMailTransportForTests(mails) after(() => setMailTransportForTests(null)) const BASE = 'https://project.webklar.com' const JETZT = new Date('2026-10-05T08:00:00.000Z') const NOTIZ = /^\d{4}-\d\d-\d\dT[\d:.]+Z System: Freischaltung wartet: \S/ const TERMINE = config.features.termine const NEWS = config.features.news // seedFixtures gibt c1 nur ein Hosting-Abo (sub_h): Termine-Faelle ueberschreiben es mit einem Wartungs-Abo const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch })) const wartung2 = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h2', { ...wartungDoc, customerId: 'c2', email: 'k2@example.invalid', stripeCustomerId: 'cus_c2', ...patch })) let nr = 0 /** * Dokument im Zustand 'setup' samt Projekt und Ticket (Standard: Ticket geschlossen). Liefert die ID des Dokuments. * Termine: dazu optional die gespeicherte Einstellung (einstellung: true). */ function einrichtung(key, { customerId = 'c1', ticket = 'Closed', einstellung = false, ...over } = {}) { nr += 1 const id = `f_${key}_${nr}` const projectId = `p_${key}_${nr}` aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt ${nr}` }) aw.seed('workorders', { $id: `t_${id}`, woid: `WO-${nr}`, status: ticket, topic: 'x' }) aw.seed('portalFeatures', { $id: id, customerId, projectId, featureKey: key, state: 'setup', enabled: false, siteKey: `siteke${String(nr).padStart(10, 'a')}`.slice(0, 16), ticketId: `t_${id}`, purchaseStatus: 'paid', unlockedByPurchase: true, ...over, }) if (einstellung) speichere(id, customerId, projectId) return id } function speichere(featureId, customerId = 'c1', projectId = 'p') { aw.seed('termineConfig', { $id: featureId, customerId, projectId, einstellung: JSON.stringify({ betrieb: { name: 'Salon' } }), version: 1, updatedAt: JETZT.toISOString(), updatedBy: 'test' }) } const feld = (id, name) => aw.docs('portalFeatures').get(id)[name] const zustand = (id) => feld(id, 'state') const wartetEintraege = (id) => String(feld(id, 'adminNote') || '').split(' | ').filter((e) => e.includes('Freischaltung wartet')) const k16 = (subject) => mails.sent.filter((m) => m.subject === subject) const STUMM = (t) => { t.mock.method(console, 'error', () => {}) t.mock.method(console, 'warn', () => {}) } beforeEach(async () => { for (const col of ['portalFeatures', 'workorders', 'termineConfig', 'subscriptions', 'websiteProjects', 'contractEvents']) { for (const id of aw.docs(col).keys()) await deleteDocument(col, id) } seedFixtures(aw) wartung() mails.sent.length = 0 config.termine.mode = 'pilot' config.termine.pilotEmails = ['*'] }) afterEach(() => { delete TERMINE.freischaltbar delete NEWS.freischaltbar NEWS.ticketFreischaltung = true }) // ---------------------------------------------------------------------------------------- Register --- test('ticketFreischaltungKeys: Register-Schluessel mit ticketFreischaltung, in Registerreihenfolge', () => { assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'zeiten']) NEWS.ticketFreischaltung = false assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'zeiten']) NEWS.ticketFreischaltung = 'ja' assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'zeiten'], 'nur true zaehlt') NEWS.ticketFreischaltung = true TERMINE.ticketFreischaltung = false try { assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'zeiten']) } finally { TERMINE.ticketFreischaltung = true } }) test('Standard-Haken: termine hat freischaltbar, news nicht', () => { assert.equal(typeof reg.featureHaken('termine').freischaltbar, 'function') assert.equal(reg.featureHaken('news').freischaltbar, undefined) }) // ------------------------------------------------------------------------------------------- News --- test('News unveraendert: Ticket geschlossen => active, K16 der News-Pinnwand, ohne Abo, Einstellung oder Notiz', async () => { const id = einrichtung('news') const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id), [id]) assert.equal(zustand(id), 'active') assert.equal(feld(id, 'enabled'), true) assert.equal(feld(id, 'activatedAt'), JETZT.toISOString()) assert.ok(!feld(id, 'adminNote'), 'keine Notiz') assert.equal(k16('Deine News-Pinnwand ist bereit').length, 1) }) test('News: offenes Ticket bleibt setup', async () => { const id = einrichtung('news', { ticket: 'Open' }) assert.deepEqual(await sf.activateClosedTickets({ now: JETZT }), []) assert.equal(zustand(id), 'setup') }) // ------------------------------------------------------------------------------ Termine: Freigabe --- test('Termine: Ticket geschlossen + Einstellung + Abo => active, K16 "Dein Terminplaner ist bereit" mit Knopf "Termine oeffnen"', async () => { const id = einrichtung('termine', { einstellung: true }) const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id), [id]) assert.equal(zustand(id), 'active') assert.equal(feld(id, 'enabled'), true) assert.equal(feld(id, 'activatedAt'), JETZT.toISOString()) assert.ok(!feld(id, 'adminNote'), 'frei => keine Wartenotiz') const gesendet = k16('Dein Terminplaner ist bereit') assert.equal(gesendet.length, 1, mails.sent.map((m) => m.subject).join(' / ')) assert.equal(mails.sent.length, 1, 'genau eine Mail') const [mail] = gesendet assert.deepEqual(mail.to.map((t) => t.address), ['k@example.invalid']) assert.match(mail.text, /Hallo Kunde Eins,\nder Terminplaner ist auf Deiner Website eingebaut und freigeschaltet\./) assert.match(mail.text, /Funktion: Terminplaner\nProjekt: Projekt \d+/) assert.ok(mail.text.includes(`Termine öffnen: ${BASE}/dashboard.html#termine`), mail.text) assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/dashboard\.html#termine"/) assert.doesNotMatch(mail.text, /News|Beitr/) // zweiter Lauf: nichts mehr offen assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(mails.sent.length, 1) }) test('Termine: Abo past_due zaehlt als laufend (wie laeuft)', async () => { wartung({ status: 'past_due' }) const id = einrichtung('termine', { einstellung: true }) assert.equal((await sf.activateClosedTickets({ now: JETZT })).length, 1) assert.equal(zustand(id), 'active') }) test('Termine: kein Abo => bleibt setup, ein adminNote-Eintrag, keine Mail; Abo kommt zurueck => naechster Lauf aktiviert und sendet K16', async (t) => { STUMM(t) const id = einrichtung('termine', { einstellung: true }) // c1 hat nur ein Hosting-Abo: laeuft false aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' })) assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(zustand(id), 'setup') assert.equal(feld(id, 'enabled'), false) assert.equal(wartetEintraege(id).length, 1) assert.match(feld(id, 'adminNote'), NOTIZ) assert.equal(mails.sent.length, 0, 'keine Mail alle 5 Minuten') // zweiter und dritter Lauf: weder ein zweiter Eintrag noch eine Mail const notiz = feld(id, 'adminNote') assert.deepEqual(await sf.runActivation({ now: new Date(JETZT.getTime() + 5 * 60000) }), []) assert.deepEqual(await sf.runActivation({ now: new Date(JETZT.getTime() + 10 * 60000) }), []) assert.equal(feld(id, 'adminNote'), notiz, 'Notiz unveraendert') assert.equal(wartetEintraege(id).length, 1) assert.equal(mails.sent.length, 0) // Abo kommt zurueck wartung() const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id), [id]) assert.equal(zustand(id), 'active') assert.equal(k16('Dein Terminplaner ist bereit').length, 1) assert.equal(mails.sent.length, 1) assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(mails.sent.length, 1) }) test('Termine: Abo beendet (canceled) => bleibt setup', async (t) => { STUMM(t) wartung({ status: 'canceled' }) const id = einrichtung('termine', { einstellung: true }) assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(zustand(id), 'setup') assert.match(feld(id, 'adminNote'), /Freischaltung wartet/) assert.equal(mails.sent.length, 0) }) test('Termine: Wechsel-Widerruf (Spiegel Wartung active, juengstes wechsel-Ereignis widerrufen) => bleibt setup, keine Mail; ein neueres erledigtes Ereignis gibt frei', async (t) => { STUMM(t) const wechsel = (nr, status) => aw.seed('contractEvents', { $id: `ev_w${nr}`, $createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, nr)).toISOString(), customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', details: '{}', status, }) const id = einrichtung('termine', { einstellung: true }) wechsel(1, 'widerrufen') assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(zustand(id), 'setup') assert.equal(feld(id, 'enabled'), false) assert.equal(wartetEintraege(id).length, 1) assert.equal(mails.sent.length, 0) wechsel(2, 'erledigt') assert.deepEqual((await sf.runActivation({ now: JETZT })).map((d) => d.$id), [id]) assert.equal(zustand(id), 'active') assert.equal(k16('Dein Terminplaner ist bereit').length, 1) }) test('Termine: keine Einstellung => bleibt setup (mit Notiz), mit gespeicherter Einstellung aktiviert der naechste Lauf', async (t) => { STUMM(t) const id = einrichtung('termine') assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(zustand(id), 'setup') assert.equal(wartetEintraege(id).length, 1) assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(wartetEintraege(id).length, 1, 'kein zweiter Eintrag') assert.equal(mails.sent.length, 0) speichere(id, 'c1', feld(id, 'projectId')) assert.equal((await sf.runActivation({ now: JETZT })).length, 1) assert.equal(zustand(id), 'active') assert.equal(k16('Dein Terminplaner ist bereit').length, 1) }) test('Termine: aendert sich der Grund, kommt ein neuer Eintrag; Notiz bleibt auf 300 Zeichen und aeltere Eintraege bleiben stehen', async (t) => { STUMM(t) const id = einrichtung('termine', { adminNote: '2026-10-01T10:00:00.000Z Andrej: activate – Kulanz' }) await sf.activateClosedTickets({ now: JETZT }) assert.equal(wartetEintraege(id).length, 1) assert.match(feld(id, 'adminNote'), /\| 2026-10-01T10:00:00\.000Z Andrej: activate – Kulanz$/, 'aelterer Eintrag bleibt') assert.ok(feld(id, 'adminNote').startsWith(JETZT.toISOString()), 'neuester Eintrag vorn, mit der Zeit des Laufs') assert.ok(feld(id, 'adminNote').length <= 300) }) // ------------------------------------------------------------------------------- Termine: Fehler --- test('Abo-Lesefehler => bleibt setup, runActivation wirft nicht, andere Dokumente werden trotzdem bearbeitet', async (t) => { STUMM(t) const termine = einrichtung('termine', { einstellung: true }) const news = einrichtung('news') aw.failNext({ collection: 'subscriptions', status: 500 }) const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id), [news]) assert.equal(zustand(termine), 'setup', 'nie freischalten, wenn das Abo nicht lesbar war') assert.equal(zustand(news), 'active') assert.match(feld(termine, 'adminNote'), /Freischaltung wartet/) assert.equal(k16('Deine News-Pinnwand ist bereit').length, 1) assert.equal(k16('Dein Terminplaner ist bereit').length, 0) const geloggt = console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n') assert.match(geloggt, /Freischaltung termine \(f_termine_\d+\) nicht prüfbar/, 'Fehler wird geloggt') // der naechste Lauf liest wieder und aktiviert const spaeter = await sf.runActivation({ now: JETZT }) assert.deepEqual(spaeter.map((d) => d.$id), [termine]) assert.equal(zustand(termine), 'active') }) test('Lesefehler bei der Einstellung (termineConfig) => bleibt setup, kein Wurf', async (t) => { STUMM(t) const id = einrichtung('termine', { einstellung: true }) aw.failNext({ collection: 'termineConfig', status: 500 }) assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(zustand(id), 'setup') assert.equal(mails.sent.length, 0) }) test('Haken wirft => bleibt setup (Log, Notiz), die uebrigen Dokumente werden bearbeitet', async (t) => { STUMM(t) TERMINE.freischaltbar = async () => { throw new Error('Pruefung kaputt') } const termine = einrichtung('termine', { einstellung: true }) const news = einrichtung('news') assert.deepEqual((await sf.runActivation({ now: JETZT })).map((d) => d.$id), [news]) assert.equal(zustand(termine), 'setup') assert.match(feld(termine, 'adminNote'), /Freischaltung wartet/) assert.match(console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n'), /Pruefung kaputt/) }) test('Haken meldet etwas anderes als true (false, undefined, Text) => bleibt setup (fail-closed)', async (t) => { STUMM(t) const id = einrichtung('termine', { einstellung: true }) for (const antwort of [false, undefined, null, 'ja', 1]) { TERMINE.freischaltbar = async () => antwort assert.deepEqual(await sf.activateClosedTickets({ now: JETZT }), [], String(antwort)) assert.equal(zustand(id), 'setup', String(antwort)) } }) test('Schreiben der Wartenotiz scheitert => kein Wurf, das naechste Dokument wird trotzdem bearbeitet', async (t) => { STUMM(t) const termine = einrichtung('termine') const news = einrichtung('news') aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500 }) const done = await sf.activateClosedTickets({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id), [news]) assert.equal(zustand(termine), 'setup') assert.equal(zustand(news), 'active') }) // ------------------------------------------------------------------------ Sperre und frisches Lesen --- test('Abo endet zwischen Listenlesen und Sperre (der Haken meldet beim frischen Lesen false) => bleibt setup', async (t) => { STUMM(t) const id = einrichtung('termine', { einstellung: true }) let lauf await withLocks([`feature-activate:${id}`], async () => { // der Lauf liest die Liste und das Ticket und wartet dann auf die Sperre lauf = sf.activateClosedTickets({ now: JETZT }) for (let i = 0; i < 100 && aw.listCalls('portalFeatures').length === 0; i += 1) await new Promise((r) => setTimeout(r, 5)) await new Promise((r) => setTimeout(r, 50)) wartung({ status: 'canceled' }) }) assert.deepEqual(await lauf, []) assert.equal(zustand(id), 'setup') assert.match(feld(id, 'adminNote'), /Freischaltung wartet/) }) test('Der Haken bekommt das unter der Sperre frisch gelesene Dokument; ein zwischendurch aktiviertes wird nicht noch einmal angefasst', async () => { const gesehen = [] TERMINE.freischaltbar = async (doc) => { gesehen.push({ state: doc.state, marker: doc.adminNote }) return true } const frisch = einrichtung('termine') const schonAktiv = einrichtung('termine') let lauf await withLocks([`feature-activate:${frisch}`, `feature-activate:${schonAktiv}`], async () => { lauf = sf.activateClosedTickets({ now: JETZT }) for (let i = 0; i < 100 && aw.listCalls('portalFeatures').length === 0; i += 1) await new Promise((r) => setTimeout(r, 5)) await new Promise((r) => setTimeout(r, 50)) aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(frisch), adminNote: 'frisch gelesen' }) aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(schonAktiv), state: 'active', enabled: true }) }) const done = await lauf assert.deepEqual(done.map((d) => d.$id), [frisch]) assert.deepEqual(gesehen, [{ state: 'setup', marker: 'frisch gelesen' }], 'der Haken sieht nur das frische, noch offene Dokument') }) // ------------------------------------------------------------------------------- Tickets, Kunden --- test('Ticket Cancelled (oder Open) => bleibt setup, der Haken wird gar nicht gefragt', async () => { let gefragt = 0 TERMINE.freischaltbar = async () => { gefragt += 1 return true } const a = einrichtung('termine', { einstellung: true, ticket: 'Cancelled' }) const b = einrichtung('termine', { einstellung: true, ticket: 'Open' }) assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(zustand(a), 'setup') assert.equal(zustand(b), 'setup') assert.equal(gefragt, 0) assert.equal(mails.sent.length, 0) }) test('customerId-Filter: nur Dokumente dieses Kunden', async () => { wartung2() const eins = einrichtung('termine', { einstellung: true }) const zwei = einrichtung('termine', { einstellung: true, customerId: 'c2' }) const done = await sf.runActivation({ now: JETZT, customerId: 'c1' }) assert.deepEqual(done.map((d) => d.$id), [eins]) assert.equal(zustand(eins), 'active') assert.equal(zustand(zwei), 'setup', 'anderer Kunde bleibt liegen') assert.deepEqual(mails.sent.map((m) => m.to[0].address), ['k@example.invalid']) // ohne Filter kommt der andere Kunde dran assert.deepEqual((await sf.runActivation({ now: JETZT })).map((d) => d.$id), [zwei]) assert.deepEqual(mails.sent.map((m) => m.to[0].address), ['k@example.invalid', 'k2@example.invalid']) }) // -------------------------------------------------------------------------------- Haken je Funktion --- test('Der Haken gilt nur fuer seine Funktion: ein News-Dokument im selben Lauf wird aktiviert', async (t) => { STUMM(t) const gefragt = [] TERMINE.freischaltbar = async (doc) => { gefragt.push(doc.featureKey) return false } const termine = einrichtung('termine', { einstellung: true }) const news = einrichtung('news') const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id), [news]) assert.equal(zustand(termine), 'setup') assert.equal(zustand(news), 'active') assert.deepEqual(gefragt, ['termine'], 'der Termine-Haken wird nie fuer News gefragt') }) test('Ein Haken im Register ueberschreibt den Standard: true gibt frei, auch ohne Abo und Einstellung', async () => { aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' })) TERMINE.freischaltbar = async () => true const id = einrichtung('termine') assert.equal((await sf.runActivation({ now: JETZT })).length, 1) assert.equal(zustand(id), 'active') assert.equal(k16('Dein Terminplaner ist bereit').length, 1) }) test('Funktionen ohne ticketFreischaltung im Register werden nicht geoeffnet', async () => { NEWS.ticketFreischaltung = false const news = einrichtung('news') assert.deepEqual(await sf.activateClosedTickets({ now: JETZT }), []) assert.equal(zustand(news), 'setup') }) test('Dokument einer anderen Funktion mit setup (analyse) bleibt unberuehrt', async () => { const analyse = einrichtung('analyse') const unbekannt = einrichtung('gibtsnicht') const termine = einrichtung('termine', { einstellung: true }) const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id), [termine]) for (const id of [analyse, unbekannt]) { assert.equal(zustand(id), 'setup', id) assert.equal(feld(id, 'enabled'), false, id) assert.ok(!feld(id, 'adminNote'), id) } assert.equal(mails.sent.length, 1) }) // --------------------------------------------------------------------------------- parallele Laeufe --- test('Parallele Laeufe (runActivation() und runActivation({ customerId })) => ein Terminplaner-K16', async () => { const id = einrichtung('termine', { einstellung: true }) const [a, b] = await Promise.all([sf.runActivation({ now: JETZT }), sf.runActivation({ now: JETZT, customerId: 'c1' })]) assert.equal(a.length + b.length, 1) assert.equal(zustand(id), 'active') assert.equal(k16('Dein Terminplaner ist bereit').length, 1) assert.equal(mails.sent.length, 1) }) test('Parallele Laeufe ohne Abo => hoechstens eine Wartenotiz, keine Mail', async (t) => { STUMM(t) aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' })) const id = einrichtung('termine', { einstellung: true }) await Promise.all([sf.runActivation({ now: JETZT }), sf.runActivation({ now: JETZT, customerId: 'c1' }), sf.runActivation({ now: JETZT })]) assert.equal(zustand(id), 'setup') assert.equal(wartetEintraege(id).length, 1) assert.equal(mails.sent.length, 0) })