import { test, after, beforeEach } 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' // Freischalt-Hindernisse und Freigabe-Mail des Terminplaners (Task D6, aus B11 herausgeloest): // freischaltHindernis(doc, { abo }) => null | 'kein_abo' | 'keine_einstellung' | 'abo_unbekannt' // (Abo zuerst, dann Einstellung; ein Abo-Lesefehler gibt nie frei), // meldeFreigeschaltet(doc, { attachments }) = K16 an den Kunden (Fehler geloggt, nie geworfen), // der Standardhaken freischaltbar(doc) ist (await freischaltHindernis(doc)) === null, // runActivation ruft meldeFreigeschaltet fuer jedes aktivierte Dokument. // Die Tests von B11 (featureFreischaltung.test.js) laufen unveraendert weiter. 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 { setMailTransportForTests } = await import('../server/services/mailer.js') const { mailSink } = await import('./support/mailSink.js') const { aboMemo } = await import('../server/services/aboStatus.js') const reg = await import('../server/services/featureRegistry.js') const sf = await import('../server/services/siteFeatures.js') const { freischaltHindernis, meldeFreigeschaltet } = await import('../server/services/termine/freischaltung.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') // 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 nurHosting = () => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' })) let nr = 0 /** Ein portalFeatures-Dokument im Zustand 'setup' mit Projekt und geschlossenem Ticket; mit einstellung: true dazu die gespeicherte Einstellung. */ function einrichtung(key = 'termine', { customerId = 'c1', einstellung = false } = {}) { 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: 'Closed', 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, }) if (einstellung) speichere(id, customerId, projectId) return aw.docs('portalFeatures').get(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 k16 = (subject) => mails.sent.filter((m) => m.subject === subject) const geloggt = () => console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n') 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', 'customers']) { 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 = ['*'] }) // ------------------------------------------------------------------------------ freischaltHindernis --- test('freischaltHindernis: Wartungs-Abo da + Einstellung da => null', async () => { const doc = einrichtung('termine', { einstellung: true }) assert.equal(await freischaltHindernis(doc), null) }) test('freischaltHindernis: kein Wartungs-Abo => kein_abo, auch ohne Einstellung (Abo zuerst)', async () => { nurHosting() const mit = einrichtung('termine', { einstellung: true }) const ohne = einrichtung('termine') assert.equal(await freischaltHindernis(mit), 'kein_abo') assert.equal(await freischaltHindernis(ohne), 'kein_abo', 'Abo zuerst: ohne Abo und ohne Einstellung steht kein_abo da') // c3 hat gar kein Abo const c3 = einrichtung('termine', { customerId: 'c3' }) assert.equal(await freischaltHindernis(c3), 'kein_abo') }) test('freischaltHindernis: Abo da, keine Einstellung => keine_einstellung', async () => { const doc = einrichtung('termine') assert.equal(await freischaltHindernis(doc), 'keine_einstellung') speichere(doc.$id, 'c1', doc.projectId) assert.equal(await freischaltHindernis(doc), null, 'mit gespeicherter Einstellung frei') }) test('freischaltHindernis: Abo-Lesefehler (failNext auf subscriptions) => abo_unbekannt, geloggt, nie null', async (t) => { STUMM(t) const doc = einrichtung('termine', { einstellung: true }) aw.failNext({ collection: 'subscriptions', status: 500, message: 'Nachbau: Abo kaputt' }) assert.equal(await freischaltHindernis(doc), 'abo_unbekannt') assert.match(geloggt(), new RegExp(`Freischaltung termine \\(${doc.$id}\\) nicht prüfbar: .*Nachbau: Abo kaputt`)) // der Fehler war einmalig: die naechste Frage liest wieder und gibt frei assert.equal(await freischaltHindernis(doc), null) }) test('freischaltHindernis: Abo past_due zaehlt als laufend => null', async () => { wartung({ status: 'past_due' }) const doc = einrichtung('termine', { einstellung: true }) assert.equal(await freischaltHindernis(doc), null) }) test('freischaltHindernis: Wechsel-Widerruf (E1: Spiegel Wartung active, juengstes wechsel-Ereignis widerrufen) => kein_abo; ein neueres erledigtes gibt frei', async () => { const wechsel = (n, status) => aw.seed('contractEvents', { $id: `ev_w${n}`, $createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, n)).toISOString(), customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', details: '{}', status, }) const doc = einrichtung('termine', { einstellung: true }) wechsel(1, 'widerrufen') assert.equal(await freischaltHindernis(doc), 'kein_abo') wechsel(2, 'erledigt') assert.equal(await freischaltHindernis(doc), null) }) test('freischaltHindernis: Abo beendet (canceled) => kein_abo', async () => { wartung({ status: 'canceled' }) const doc = einrichtung('termine', { einstellung: true }) assert.equal(await freischaltHindernis(doc), 'kein_abo') }) test('freischaltHindernis: ohne Abo wird die Einstellung gar nicht gelesen (ihr Lesefehler stoert dann nicht)', async () => { nurHosting() const doc = einrichtung('termine', { einstellung: true }) aw.failNext({ collection: 'termineConfig', status: 500 }) assert.equal(await freischaltHindernis(doc), 'kein_abo') // der eingeplante Fehler ist noch offen (nichts hat termineConfig gelesen); er trifft jetzt die Einstellungs-Pruefung wartung() await assert.rejects(() => freischaltHindernis(doc)) }) test('freischaltHindernis: Lesefehler bei der Einstellung wirft (kein Freigeben, der Aufrufer faengt es)', async () => { const doc = einrichtung('termine', { einstellung: true }) aw.failNext({ collection: 'termineConfig', status: 500 }) await assert.rejects(() => freischaltHindernis(doc)) }) test('freischaltHindernis: fragt das Abo des Kunden des Dokuments; ein eingereichtes abo ersetzt den Standard', async () => { const doc = einrichtung('termine', { customerId: 'c2', einstellung: true }) const gefragt = [] const stub = (antwort) => ({ laeuft: async (customerId) => { gefragt.push(customerId) if (antwort instanceof Error) throw antwort return antwort }, }) assert.equal(await freischaltHindernis(doc, { abo: stub(true) }), null) assert.equal(await freischaltHindernis(doc, { abo: stub(false) }), 'kein_abo') assert.deepEqual(gefragt, ['c2', 'c2']) }) test('freischaltHindernis: abo.laeuft wirft => abo_unbekannt (auch bei einem eingereichten abo)', async (t) => { STUMM(t) const doc = einrichtung('termine', { einstellung: true }) const abo = { laeuft: async () => { throw new Error('Memo: Ablehnung bleibt') }, } assert.equal(await freischaltHindernis(doc, { abo }), 'abo_unbekannt') assert.match(geloggt(), /Memo: Ablehnung bleibt/) }) test('freischaltHindernis: ein gemeinsamer aboMemo() liest das Abo je Kunde nur einmal; ohne abo liest jeder Aufruf frisch', async () => { const doc = einrichtung('termine', { einstellung: true }) aw.resetListCalls() const memo = aboMemo() assert.equal(await freischaltHindernis(doc, { abo: memo }), null) assert.equal(await freischaltHindernis(doc, { abo: memo }), null) assert.equal(aw.listCalls('subscriptions').length, 1, 'Memo: ein Lesen') // ohne abo: frisch gelesen, ein Abo, das dazwischen endet, gibt nicht mehr frei assert.equal(await freischaltHindernis(doc), null) wartung({ status: 'canceled' }) assert.equal(await freischaltHindernis(doc), 'kein_abo') }) // ---------------------------------------------------------------------------- Delegation des Standardhakens --- test('Standardhaken freischaltbar: true nur bei null, false bei kein_abo / keine_einstellung / abo_unbekannt', async (t) => { STUMM(t) const { freischaltbar } = reg.featureHaken('termine') assert.equal(typeof freischaltbar, 'function') const doc = einrichtung('termine') assert.equal(await freischaltbar(doc), false, 'keine_einstellung') speichere(doc.$id, 'c1', doc.projectId) assert.equal(await freischaltbar(doc), true, 'null') aw.failNext({ collection: 'subscriptions', status: 500 }) assert.equal(await freischaltbar(doc), false, 'abo_unbekannt: fail-closed, kein Wurf') wartung({ status: 'canceled' }) assert.equal(await freischaltbar(doc), false, 'kein_abo') }) // ------------------------------------------------------------------------------------ meldeFreigeschaltet --- test('meldeFreigeschaltet: K16 mit "Terminplaner" im Betreff und Link #termine, genau eine Mail an den Kunden', async () => { const doc = einrichtung('termine', { einstellung: true }) await meldeFreigeschaltet(doc) assert.equal(mails.sent.length, 1, mails.sent.map((m) => m.subject).join(' / ')) const [mail] = k16('Dein Terminplaner ist bereit') assert.ok(mail, mails.sent.map((m) => m.subject).join(' / ')) assert.match(mail.subject, /Terminplaner/) assert.deepEqual(mail.to.map((t) => t.address), ['k@example.invalid']) assert.match(mail.text, /Hallo Kunde Eins,/) 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.deepEqual(mail.attachments ?? [], [], 'ohne attachments kein Anhang') }) test('meldeFreigeschaltet: attachments (die PDFs der Staff-Aktivierung) stehen in der gesendeten Mail', async () => { const doc = einrichtung('termine', { einstellung: true }) const pdf = (filename) => ({ filename, content: Buffer.from(`%PDF-1.4\n% ${filename}\n`), contentType: 'application/pdf' }) await meldeFreigeschaltet(doc, { attachments: [pdf('Leistungsbeschreibung.pdf'), pdf('AVV.pdf')] }) assert.equal(mails.sent.length, 1) const [mail] = mails.sent assert.equal(mail.subject, 'Dein Terminplaner ist bereit') assert.deepEqual(mail.attachments.map((a) => [a.filename, a.contentType]), [ ['Leistungsbeschreibung.pdf', 'application/pdf'], ['AVV.pdf', 'application/pdf'], ]) for (const a of mail.attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF') }) test('meldeFreigeschaltet gilt fuer jede Funktion des Registers: News => K16 der News-Pinnwand mit Link #news', async () => { const doc = einrichtung('news') await meldeFreigeschaltet(doc) const [mail] = k16('Deine News-Pinnwand ist bereit') assert.ok(mail, mails.sent.map((m) => m.subject).join(' / ')) assert.ok(mail.text.includes(`${BASE}/dashboard.html#news`), mail.text) assert.equal(mails.sent.length, 1) }) test('meldeFreigeschaltet: Versandfehler => kein Wurf, console.error mit Kunde und Grund', async (t) => { STUMM(t) const doc = einrichtung('termine', { einstellung: true }) setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP: Postfach voll') }, }) try { await assert.doesNotReject(() => meldeFreigeschaltet(doc)) } finally { setMailTransportForTests(mails) } assert.equal(console.error.mock.calls.length, 1) assert.match(geloggt(), /K16 an Kunde c1 fehlgeschlagen/) assert.match(geloggt(), /SMTP: Postfach voll/) assert.equal(mails.sent.length, 0) }) test('meldeFreigeschaltet: Kunde ohne E-Mail oder unbekannt => keine Mail, kein Wurf, kein Fehler im Log', async (t) => { STUMM(t) aw.seed('customers', { $id: 'c_leer', appwriteUserId: 'u_leer', email: '', name: 'Ohne Adresse', status: 'active' }) const ohneMail = einrichtung('termine', { customerId: 'c_leer' }) const unbekannt = einrichtung('termine', { customerId: 'c_gibtsnicht' }) await meldeFreigeschaltet(ohneMail) await meldeFreigeschaltet(unbekannt) assert.equal(mails.sent.length, 0) assert.equal(console.error.mock.calls.length, 0) }) test('meldeFreigeschaltet: Kunde nicht lesbar oder Funktion nicht im Register => kein Wurf, geloggt, keine Mail', async (t) => { STUMM(t) const doc = einrichtung('termine', { einstellung: true }) aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Nachbau: Kunden kaputt' }) await assert.doesNotReject(() => meldeFreigeschaltet(doc)) assert.match(geloggt(), /K16 an Kunde c1 fehlgeschlagen.*Nachbau: Kunden kaputt/) const fremd = einrichtung('gibtsnicht') await assert.doesNotReject(() => meldeFreigeschaltet(fremd)) assert.match(geloggt(), /K16 an Kunde c1 fehlgeschlagen.*gibtsnicht nicht im Register/) assert.equal(mails.sent.length, 0) }) test('meldeFreigeschaltet liefert true nur, wenn K16 rausging: Erfolg true; Versandfehler, nicht lesbarer Kunde, unbekannte Funktion, Kunde ohne oder unbekannt false (A2)', async (t) => { STUMM(t) const doc = einrichtung('termine', { einstellung: true }) assert.equal(await meldeFreigeschaltet(doc), true) assert.equal(mails.sent.length, 1) setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP: Postfach voll') }, }) try { assert.equal(await meldeFreigeschaltet(doc), false, 'Versandfehler') } finally { setMailTransportForTests(mails) } aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Nachbau: Kunden kaputt' }) assert.equal(await meldeFreigeschaltet(doc), false, 'Kunde nicht lesbar') assert.equal(await meldeFreigeschaltet(einrichtung('gibtsnicht')), false, 'Funktion nicht im Register') aw.seed('customers', { $id: 'c_leer2', appwriteUserId: 'u_leer2', email: '', name: 'Ohne Adresse', status: 'active' }) assert.equal(await meldeFreigeschaltet(einrichtung('termine', { customerId: 'c_leer2' })), false, 'Kunde ohne Adresse') assert.equal(await meldeFreigeschaltet(einrichtung('termine', { customerId: 'c_gibtsnicht' })), false, 'Kunde unbekannt') assert.equal(mails.sent.length, 1, 'nur die erste Mail ging raus') }) // -------------------------------------------------------------------------------------------- runActivation --- test('runActivation sendet fuer ein aktiviertes Termine-Dokument genau ein K16 (ueber meldeFreigeschaltet), ein zweiter Lauf keins', async () => { const doc = einrichtung('termine', { einstellung: true }) const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id), [doc.$id]) assert.equal(aw.docs('portalFeatures').get(doc.$id).state, 'active') assert.equal(mails.sent.length, 1) assert.equal(k16('Dein Terminplaner ist bereit').length, 1) assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(mails.sent.length, 1) }) test('runActivation: ein fehlgeschlagener K16-Versand haelt weder die Aktivierung noch die uebrigen Mails auf', async (t) => { STUMM(t) const erstes = einrichtung('termine', { einstellung: true }) const zweites = einrichtung('termine', { customerId: 'c2', einstellung: true }) aw.seed('subscriptions', subscriptionDoc('sub_h2', { ...wartungDoc, customerId: 'c2', email: 'k2@example.invalid', stripeCustomerId: 'cus_c2' })) let n = 0 setMailTransportForTests({ sendMail: async (mail) => { n += 1 if (n === 1) throw new Error('SMTP: erster Versand scheitert') return mails.sendMail(mail) }, }) let done try { done = await sf.runActivation({ now: JETZT }) } finally { setMailTransportForTests(mails) } assert.deepEqual(done.map((d) => d.$id).sort(), [erstes.$id, zweites.$id].sort()) for (const doc of [erstes, zweites]) assert.equal(aw.docs('portalFeatures').get(doc.$id).state, 'active') assert.equal(mails.sent.length, 1, 'die zweite Mail geht trotzdem raus') assert.match(geloggt(), /K16 an Kunde c\d fehlgeschlagen.*erster Versand scheitert/) })