diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index 893050d..b8dca68 100644 --- a/server/services/featureRegistry.js +++ b/server/services/featureRegistry.js @@ -27,14 +27,15 @@ async function termineBestellpruefung({ customerId, projectId, feature }) { /** * Freischaltpruefung des Terminplaners (Spec 4.5: die Aktivierung bleibt gesperrt, bis ein Abo laeuft): die Einrichtung - * ist erst freizugeben, wenn ein Wartungs-Abo laeuft und eine Einstellung gespeichert ist. Fail-closed: ein Lesefehler - * (Abo, Einstellung) wirft, activateClosedTickets haelt das Dokument dann in 'setup'. Der Aufrufer liest das Dokument - * unter der Sperre frisch und uebergibt es; auch das Abo wird hier frisch gelesen, nie aus einem Zwischenspeicher. + * ist erst freizugeben, wenn ein Wartungs-Abo laeuft und eine Einstellung gespeichert ist. Das entscheidet + * freischaltHindernis (termine/freischaltung.js, auch die Gruende dort); dieser Haken gibt nur bei null frei. Fail-closed: + * ein nicht lesbares Abo ('abo_unbekannt') gibt nie frei, ein Lesefehler der Einstellung wirft, activateClosedTickets haelt + * das Dokument dann in 'setup'. Der Aufrufer liest das Dokument unter der Sperre frisch und uebergibt es; auch das Abo wird + * hier frisch gelesen (freischaltHindernis ohne eingereichten Zwischenspeicher), nie aus einem Zwischenspeicher. */ async function termineFreischaltbar(doc) { - const [{ ladeEinstellung }, { laeuft }] = await Promise.all([import('./termine/kontext.js'), import('./aboStatus.js')]) - if (!(await laeuft(doc.customerId))) return false - return (await ladeEinstellung(doc.$id)) !== null + const { freischaltHindernis } = await import('./termine/freischaltung.js') + return (await freischaltHindernis(doc)) === null } /** diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 508866b..af28dfb 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -1010,22 +1010,15 @@ export async function handleFeatureCheckoutEvent(event) { } } -/** Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active', K16 an jeden betroffenen Kunden. */ +/** + * Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active', K16 an jeden betroffenen Kunden (meldeFreigeschaltet: Fehler + * werden geloggt und halten weder die Aktivierung noch die uebrigen Mails auf). termine/freischaltung.js importiert diese + * Datei, darum wird es hier erst im Aufruf geladen. + */ export async function runActivation({ now = new Date(), customerId = null } = {}) { const activated = await activateClosedTickets({ now, customerId }) - for (const doc of activated) { - try { - const customer = await customerOf(doc.customerId) - if (!customer?.email) continue - const feature = featureConfig(doc.featureKey) - if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`) - await sendCustomerMail( - customer.email, - bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) }) - ) - } catch (err) { - console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message) - } - } + if (activated.length === 0) return activated + const { meldeFreigeschaltet } = await import('./termine/freischaltung.js') + for (const doc of activated) await meldeFreigeschaltet(doc) return activated } diff --git a/server/services/termine/freischaltung.js b/server/services/termine/freischaltung.js new file mode 100644 index 0000000..126660f --- /dev/null +++ b/server/services/termine/freischaltung.js @@ -0,0 +1,57 @@ +import { aboMemo } from '../aboStatus.js' +import { featureConfig } from '../featureRegistry.js' +import { sendCustomerMail } from '../mailer.js' +import { bereitMail, customerOf, projectName } from '../siteFeatures.js' +import { ladeEinstellung } from './kontext.js' + +// Freischalt-Hindernisse und Freigabe-Mail des Terminplaners (TP2 D6, aus B11 herausgeloest). Beide Funktionen stehen hier, +// damit der Standardhaken freischaltbar (featureRegistry.js), der Freischalt-Job (siteFeatures.runActivation) und die +// Staff-Aktivierung (F13a) dieselbe Antwort und denselben Mailweg nutzen. siteFeatures.js und featureRegistry.js laden diese +// Datei erst im Aufruf (dynamisches import()), weil sie selbst siteFeatures.js importiert. + +/** + * Was hindert die Freischaltung dieses Termine-Dokuments? null = nichts; sonst der Grund: + * 'kein_abo' kein laufendes Wartungs-Abo (inkl. beendet, Wechsel-Widerruf E1; past_due und vorgemerkte Kuendigung laufen noch) + * 'keine_einstellung' Abo laeuft, aber es ist noch keine Einstellung gespeichert + * 'abo_unbekannt' das Abo war nicht lesbar: nie freischalten, wenn etwas nicht lesbar war (der Fehler wird geloggt) + * Reihenfolge: Abo zuerst, dann Einstellung; ohne Abo wird die Einstellung gar nicht gelesen. Ein Lesefehler bei der + * Einstellung wirft (anders als beim Abo steht dafuer kein Grundcode bereit): der Aufrufer, activateClosedTickets, haelt das + * Dokument dann in 'setup'. + * abo: Fragen an das Abo, { laeuft(customerId) }; Standard aboMemo() je Aufruf, also frisch gelesen. Wer viele Dokumente + * im Listenlauf prueft, uebergibt einen gemeinsamen aboMemo(); Pruefungen unter einer Sperre lesen frisch (Standard). + * @param {{ $id: string, customerId: string, featureKey?: string }} doc portalFeatures-Dokument + * @returns {Promise} + */ +export async function freischaltHindernis(doc, { abo = aboMemo() } = {}) { + let laeuft + try { + laeuft = await abo.laeuft(doc.customerId) + } catch (err) { + console.error(`[funktionen] Freischaltung ${doc.featureKey || 'termine'} (${doc.$id}) nicht prüfbar:`, err.message) + return 'abo_unbekannt' + } + if (!laeuft) return 'kein_abo' + if ((await ladeEinstellung(doc.$id)) === null) return 'keine_einstellung' + return null +} + +/** + * K16 (Funktion bereit) an den Kunden eines freigeschalteten Dokuments, Werte aus dem Register-Eintrag der Funktion (bereitMail). + * Fehler (Kunde, Register, Versand) werden geloggt und nie geworfen: die Freischaltung ist dann schon geschehen, und eine Mail, + * die nicht rausgeht, darf die uebrigen Dokumente des Laufs nicht aufhalten. Kunde ohne Adresse: keine Mail, kein Fehler. + * attachments: Anhaenge der Mail, z. B. die PDFs der Staff-Aktivierung (F13a). + * @param {{ customerId: string, projectId: string, featureKey: string }} doc portalFeatures-Dokument + * @param {{ attachments?: object[] }} [optionen] + */ +export async function meldeFreigeschaltet(doc, { attachments = [] } = {}) { + try { + const customer = await customerOf(doc.customerId) + if (!customer?.email) return + const feature = featureConfig(doc.featureKey) + if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`) + const mail = bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) }) + await sendCustomerMail(customer.email, { ...mail, attachments }) + } catch (err) { + console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message) + } +} diff --git a/test/termineFreischaltung.test.js b/test/termineFreischaltung.test.js new file mode 100644 index 0000000..7bff585 --- /dev/null +++ b/test/termineFreischaltung.test.js @@ -0,0 +1,353 @@ +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) +}) + +// -------------------------------------------------------------------------------------------- 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/) +})