Files
Webklar-Kundenbereich/test/termineFreischaltung.test.js
T
ClaudeandClaude Sonnet 5.5 8fe14c2fe6 Funktionen: Freischalt-Hindernisse und Freigabe-Mail ausgelagert
freischaltHindernis (Abo zuerst, dann Einstellung; Abo-Lesefehler = abo_unbekannt)
und meldeFreigeschaltet (K16, Fehler geloggt, mit attachments) in
termine/freischaltung.js. Der Standardhaken freischaltbar fuer termine
delegiert an freischaltHindernis, runActivation an meldeFreigeschaltet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:56:35 +00:00

354 lines
16 KiB
JavaScript

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/)
})