Merge feature/kundenansicht-neu (P5, P6, P8, P6b) in kn/ui vor P3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-16 17:55:13 +00:00
commit 1cd16ff293
123 files changed
+11184 -205

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
// Anfragen im Abo-Modell (P5 Task 12): die Shop-Anfrage nimmt Unternehmer aus der Kundenart (nur Betriebe), der Buchungskalender bleibt offen
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
ukEinstellungen: schemaKeys('ukEinstellungen'),
featureConsents: schemaKeys('featureConsents'),
},
datetimes: { ukEinstellungen: ['rechnungAuftragAt', 'anfrageAt', 'anfrageEntschiedenAt', 'icalFehlerMailAt'] },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post } = await import('./support/testApp.js')
const { customerCookie, customers, seedFixtures } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { NUR_BETRIEB_TEXT } = await import('../server/services/kundenart.js')
const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js')
const { default: ukRoutes, resetUnterkunftRoutesForTests } = await import('../server/routes/unterkunft.js')
const app = await startTestApp({ '/api/shop': shopRoutes, '/api/unterkunft': ukRoutes })
const sink = mailSink()
setMailTransportForTests(sink)
const vorher = {
neu: { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] },
shop: { mode: config.shop.mode, pilotEmails: [...config.shop.pilotEmails] },
unterkunft: { mode: config.unterkunft.mode, pilotEmails: [...config.unterkunft.pilotEmails] },
}
after(() => {
Object.assign(config.kundenansichtNeu, vorher.neu)
Object.assign(config.shop, vorher.shop)
Object.assign(config.unterkunft, vorher.unterkunft)
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
const c1 = customerCookie('c1')
const anfrage = (extra = {}) => ({ warenart: 'waren', beschreibung: 'Backwaren aus eigener Herstellung', artikelzahl: 'bis20', versand: false, abholung: true, keineAusgeschlossenen: true, ...extra })
const senden = (a, cookie = c1) => post('/api/shop/anfrage', { projectId: 'p1', anfrage: a }, { cookie })
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht
const leere = async (...collections) => {
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
beforeEach(async () => {
await leere('portalFeatures', 'shopConfig', 'ukEinstellungen', 'featureConsents', 'websiteProjects')
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', archived: false })
config.kundenansichtNeu.mode = 'pilot'
config.kundenansichtNeu.pilotEmails = ['k@example.invalid', 'test@example.invalid']
config.shop.mode = config.unterkunft.mode = 'on'
resetShopRoutesForTests()
resetUnterkunftRoutesForTests()
sink.sent.length = 0
})
test('privat (Kundenart leer): 409 nur_betrieb, auch mit gefälschtem unternehmer: true; nichts geschrieben, keine Mail', async () => {
const r = await senden(anfrage({ unternehmer: true }))
assert.deepEqual([r.status, r.body.code, r.body.error], [409, 'nur_betrieb', NUR_BETRIEB_TEXT])
assert.deepEqual([aw.docs('shopConfig').size, aw.docs('portalFeatures').size, sink.sent.length], [0, 0, 0])
for (const kundenart of ['', 'kaputt', 'Unternehmen', 'privat']) {
aw.seed('customers', { ...customers.c1, kundenart })
const x = await senden(anfrage({ unternehmer: true, kundenart: 'unternehmen' }))
assert.deepEqual([x.status, x.body.code], [409, 'nur_betrieb'], kundenart)
}
})
test('Betrieb: unternehmer kommt vom Server, Anfrage speichert die Kundenart', async () => {
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
const r = await senden(anfrage())
assert.deepEqual([r.status, r.body.anfrage.status], [200, 'offen'])
assert.deepEqual([r.body.anfrage.anfrage.unternehmer, r.body.anfrage.anfrage.kundenart], [true, 'unternehmen'])
assert.equal(sink.sent.length, 1, 'T21 ans Team')
await leere('shopConfig', 'portalFeatures')
const falsch = await senden(anfrage({ unternehmer: false })) // der Client-Wert zählt nicht
assert.deepEqual([falsch.status, falsch.body.anfrage.anfrage.unternehmer], [200, true])
await leere('shopConfig', 'portalFeatures')
const ohne = await senden(anfrage({ keineAusgeschlossenen: false }))
assert.deepEqual([ohne.status, Object.keys(ohne.body.fehler)], [400, ['keineAusgeschlossenen']])
})
test('alte Ansicht unverändert: unternehmer bleibt Pflicht, privat darf anfragen', async () => {
config.kundenansichtNeu.mode = 'off'
const r = await senden(anfrage())
assert.deepEqual([r.status, Object.keys(r.body.fehler)], [400, ['unternehmer']])
const ok = await senden(anfrage({ unternehmer: true }))
assert.equal(ok.status, 200)
assert.equal(Object.hasOwn(ok.body.anfrage.anfrage, 'kundenart'), false, 'ohne neue Ansicht keine Kundenart in der Anfrage')
})
test('Reihenfolge: Projekt und Schalter vor der Kundenart, die Kundenart vor der Eingabe', async () => {
assert.equal((await post('/api/shop/anfrage', { projectId: 'pfremd', anfrage: anfrage() }, { cookie: c1 })).status, 403)
config.shop.mode = 'off'
assert.equal((await senden(anfrage())).body.code, 'nicht_verfuegbar')
config.shop.mode = 'on'
const leer = await senden({}) // ungültige Eingabe, aber privat: nur_betrieb kommt zuerst
assert.deepEqual([leer.status, leer.body.code], [409, 'nur_betrieb'])
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
assert.equal((await senden({})).status, 400)
})
test('Buchungskalender: privat darf in der neuen Ansicht anfragen (nurBetrieb false)', async () => {
const r = await post('/api/unterkunft/anfrage-funktion', { projectId: 'p1', art: 'ferienwohnung', einheiten: 2, portale: ['booking'] }, { cookie: c1 })
assert.equal(r.status, 200, JSON.stringify(r.body))
assert.equal(r.body.anfrage.status, 'offen')
})
+33
View File
@@ -0,0 +1,33 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Aufbauphase (E9, N28): ohneAboBisher = der Kunde hatte noch nie ein Abo. Gemeinsame Quelle für die Team-Aktivierung und die Abgleiche.
const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { subscriptionDoc, wartungDoc, hostingDoc } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { ohneAboBisher } = await import('../server/services/aboAufbauphase.js')
// aw.docs() liefert eine Kopie: geloescht wird ueber die API
const loescheAbos = async () => {
for (const id of aw.docs('subscriptions').keys()) await deleteDocument('subscriptions', id)
}
beforeEach(loescheAbos)
test('ohneAboBisher: wahr ohne Abo-Dokument und bei abgebrochener Bestellung, falsch bei jedem anderen Status (auch beendet), je Kunde', async () => {
assert.equal(await ohneAboBisher('c1'), true)
for (const status of ['incomplete', 'incomplete_expired']) { await loescheAbos(); aw.seed('subscriptions', subscriptionDoc('sub_x', { ...wartungDoc, status })); assert.equal(await ohneAboBisher('c1'), true, status) }
for (const status of ['active', 'trialing', 'past_due', 'canceled', 'unpaid', 'paused']) { await loescheAbos(); aw.seed('subscriptions', subscriptionDoc('sub_x', { ...wartungDoc, status })); assert.equal(await ohneAboBisher('c1'), false, status) }
await loescheAbos(); aw.seed('subscriptions', subscriptionDoc('sub_h', hostingDoc)) // Hosting zählt als Abo; das Dokument gehört c1
assert.deepEqual([await ohneAboBisher('c1'), await ohneAboBisher('c2')], [false, true])
})
test('ohneAboBisher: Lesefehler werden weitergereicht, leere oder falsche Kunden-ID ist ein TypeError', async () => {
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }); await assert.rejects(() => ohneAboBisher('c1'))
for (const id of ['', undefined, null, 5]) await assert.rejects(() => ohneAboBisher(id), TypeError)
})
+128
View File
@@ -0,0 +1,128 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const { config } = await import('../server/config.js')
const ENTWURF = true
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
const PFLICHT = [ // stelle = Überschrift im Entwurf; von/bis = Anker-Ids der Seite (null = Dokumentanfang bzw. -ende)
{ stelle: 'Das Wichtigste auf einen Blick', von: null, bis: 'p1', texte: ['Das Hosting-Abo enthält die Website-Analyse, das Wartungs-Abo zusätzlich News-Pinnwand, Terminplaner, Webshop, Buchungskalender und Öffnungszeiten live. Das Einschalten kostet nichts extra; die Einrichtung geht vom Stundenkontingent ab (§ 4 Abs. 8).'] },
{ stelle: '§ 1 Abs. 2', von: 'p1', bis: 'p2', texte: ['Die Bestellseiten richten sich nach der Kundenart, die wir im Kundenkonto hinterlegt haben („Betrieb“ oder „privat“). Wir hinterlegen sie nach den Angaben des Kunden; ist keine hinterlegt, gilt der Kunde als privat. Ob der Kunde Verbraucher oder Unternehmer ist, richtet sich nach dem Zweck, zu dem er den Vertrag abschließt, nicht nach dieser Angabe.'] },
{ stelle: '§ 2 Abs. 2 c', von: 'p2', bis: 'p3', texte: ['Direkt über der Schaltfläche steht der Hinweis, dass diese Bedingungen mit den Leistungsbeschreibungen, dem Auftragsverarbeitungsvertrag (Anlage 1) und der Widerrufsbelehrung gelten, jeweils mit Fassung und Link; die Unterlagen erhält der Kunde nach der Bestellung zusätzlich als PDF per E-Mail. Ist die Kundenart „privat“ hinterlegt, kann der Kunde dort durch ein Kästchen verlangen, dass wir sofort mit der Leistung beginnen; ohne das Kästchen beginnen wir erst nach Ablauf der 14-tägigen Widerrufsfrist. Ist die Kundenart „Betrieb“ hinterlegt, beginnen wir mit Vertragsschluss, ohne dass der Kunde etwas markieren muss.'] },
{ stelle: '§ 2 Abs. 6', von: 'p2', bis: 'p3', texte: ['Wir speichern außerdem den Wortlaut der angezeigten Hinweise und Kästchen, die hinterlegte Kundenart sowie Zeit, IP-Adresse und Browserkennung des Klicks.'] },
{ stelle: '§ 3 Abs. 8', von: 'p3', bis: 'p4', texte: [
'Zum Abo gehören Funktionen, die der Kunde im Kundenportal unter „Funktionen“ einschaltet. Das Hosting-Abo enthält die Website-Analyse. Das Wartungs-Abo enthält News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live und Website-Analyse. Umfang, Voraussetzungen und Pflichten jeder Funktion beschreiben ihre Leistungsbeschreibung und, soweit die Funktion personenbezogene Daten im Auftrag des Kunden verarbeitet, ihr Auftragsverarbeitungsvertrag, jeweils in der Fassung, die das Kundenportal beim Einschalten anzeigt. Eine Funktion kostet keine zusätzliche Gebühr; die Einrichtung richtet sich nach § 4 Abs. 8 und 9.',
'Vorbehaltlich § 11 Abs. 4 läuft eine Funktion nur, solange das Abo läuft, das sie enthält. Das gilt auch für eine Funktion, die wir auf Wunsch des Kunden schon bei der Erstellung seiner Website eingebaut haben. Funktionen, die der Kunde vor Veröffentlichung dieser Fassung einzeln gekauft hat, laufen nach den Bedingungen weiter, unter denen er sie gekauft hat.'] },
{ stelle: '§ 4 Abs. 2', von: 'p4', bis: 'p5', texte: ['Einen Vorschuss nach Absatz 8 gleichen die folgenden Kontingente zuerst aus.'] },
{ stelle: '§ 4 Abs. 8', von: 'p4', bis: 'p5', texte: ['Die Einrichtung einer Funktion rechnen wir als Arbeitszeit auf das Stundenkontingent an. Angerechnet wird die tatsächlich aufgewendete Zeit nach Absatz 4. Reicht das verfügbare Kontingent nicht aus, führen wir die Einrichtung trotzdem ohne weitere Rückfrage aus. Die fehlende Zeit ist ein Vorschuss, den die folgenden Monatskontingente ausgleichen, sobald sie bereitstehen. Dafür berechnen wir nichts, weder als Mehraufwand nach Absatz 6 noch in einer gesonderten Rechnung. Endet das Wartungs-Abo, solange noch ein Vorschuss offen ist, verfällt der Rest; der Kunde schuldet ihn nicht.'] },
{ stelle: '§ 4 Abs. 9', von: 'p4', bis: 'p5', texte: ['Im Hosting-Abo ist die Einrichtung der Website-Analyse für den Kunden kostenlos; ein Stundenkontingent gibt es dort nicht (Absatz 7). Lässt der Kunde eine Funktion auf seinen Wunsch schon bei der Erstellung seiner Website einbauen, rechnen wir die Einrichtung nicht über das Stundenkontingent ab, sondern über die Kosten des Projekts, wie wir es mit ihm vereinbart haben.'] },
{ stelle: '§ 6 Abs. 6', von: 'p6', bis: 'p7', texte: ['Schaltet der Kunde die Website-Analyse ein, ist er für die Messung auf seiner Website Verantwortlicher. Er entfernt oder umgeht das Cookie-Banner nicht, bindet ohne Abstimmung mit uns keine weiteren Mess- oder Tracking-Werkzeuge ein und ergänzt seine Datenschutzerklärung um einen Hinweis auf die von uns erzeugte Zusatzseite „/datenschutz-analyse.html“.'] },
{ stelle: '§ 9 Abs. 6', von: 'wechsel', bis: 'zusatzspeicher', texte: ['Will der Kunde im Hosting-Abo eine Funktion einschalten, die nur das Wartungs-Abo enthält, führt ihn das Kundenportal zum Wechsel. Nach Wirksamkeit des Wechsels schaltet er die Funktion in einem eigenen Vorgang ein. Der Wechsel bleibt eine eigene Bestellung nach Absatz 2.'] },
{ stelle: '§ 11 Abs. 4', von: 'p11', bis: 'p12', texte: ['Mit dem Ende des Abos, das eine Funktion enthält, endet die Funktion; abweichend davon läuft Öffnungszeiten live noch 30 Tage unverändert weiter. Wir schalten sie ab und löschen ihre Daten nach den Fristen in ihrer Leistungsbeschreibung; die Messdaten der Website-Analyse löschen wir 60 Tage nach der Abschaltung. Beginnt innerhalb dieser Fristen wieder ein Abo, das die Funktion enthält, schalten wir sie mit den vorhandenen Daten wieder ein. Wechselt der Kunde vom Wartungs- ins Hosting-Abo, bleibt die Website-Analyse; die übrigen Funktionen enden. Das Gleiche gilt, wenn der Kunde den Wechsel ins Wartungs-Abo widerruft.'] },
{ stelle: '§ 14 Abs. 2', von: 'p14', bis: 'p15', texte: ['Für Funktionen, die personenbezogene Daten im Auftrag des Kunden verarbeiten, gilt zusätzlich der Auftragsverarbeitungsvertrag der Funktion, dem der Kunde beim Einschalten mit dem Klick auf „Einschalten“ zustimmt.'] },
{ stelle: '§ 15', von: 'p15', bis: 'p16', texte: ['Fassung 6 gilt für Abos, die ab ihrer Veröffentlichung abgeschlossen werden. Für Abos, die nach einer früheren Fassung abgeschlossen wurden, gelten diese Fassungen weiter. Schaltet der Kunde mit einem solchen Abo eine Funktion ein, gelten für die Funktion ihre Leistungsbeschreibung und ihr Auftragsverarbeitungsvertrag in der angezeigten Fassung, einschließlich der Regeln zur Einrichtung und zum Stundenkontingent.'] },
{ stelle: 'Leistungsbeschreibung A Nr. 1', von: 'hosting', bis: 'wartung', texte: ['Website-Analyse im Kundenportal (§ 3 Abs. 8)'] },
{ stelle: 'Leistungsbeschreibung B Nr. 1', von: 'wartung', bis: 'widerruf', texte: ['Funktionen im Kundenportal: News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live und Website-Analyse (§ 3 Abs. 8)'] },
{ stelle: 'Widerruf: Hinweise', von: 'widerruf-hinweise', bis: 'avv', texte: ['Das Einschalten einer Funktion ist kein eigener Vertrag, sondern Teil des Abos. Es verlängert die Widerrufsfrist des Abos nicht. Widerruft ein Verbraucher das Abo, endet auch die Funktion (§ 11 Abs. 4).'] },
{ stelle: 'Anlage 3', von: 'sub', bis: null, texte: ['Server für die Website-Analyse (Messdienst, nur bei eingeschalteter Funktion)', 'Deutschland (Rechenzentrum Falkenstein)'] },
]
test('Entwurf Fassung 6: Stellen in Reihenfolge, jeder Pflichtsatz im Wortlaut unter seiner Stelle', () => {
const md = lies('docs/rechtliches/abo-bedingungen-v6.md')
assert.match(md, /^# Abo-Bedingungen Fassung 6 \(Entwurf\)\n/)
assert.deepEqual([...md.matchAll(/^## (.+)$/gm)].map((m) => m[1]), [...PFLICHT.map((p) => p.stelle), 'Was gleich bleibt'])
assert.equal(md.includes('Entwurf zur Freigabe, keine Rechtsberatung'), ENTWURF)
for (const p of PFLICHT) {
const teil = md.slice(md.indexOf(`## ${p.stelle}\n`)).split(/\n## /)[0]
for (const t of p.texte) assert.ok(teil.includes(t), `${p.stelle}: ${t.slice(0, 50)}`)
assert.match(teil, /\*\*Begründung\.\*\*/, p.stelle)
}
assert.doesNotMatch(md.slice(0, md.indexOf('## Was gleich bleibt')), /€/, 'die Pflichtsätze nennen keinen Preis')
})
// Hilfen aus test/legalTexts.test.js (kein Import möglich)
const strip = (html) => html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim()
const between = (s, a, b) => {
const i = s.indexOf(a)
assert.ok(i >= 0, `Text fehlt: ${a}`)
const j = s.indexOf(b, i)
assert.ok(j > i, `Text fehlt: ${b}`)
return s.slice(i, j)
}
const V6 = 'public/neu/rechtliches/abo-bedingungen.html', V5A = 'public/vertragsunterlagen/v5/abo-bedingungen.html'
const abschnitt = (html, von, bis) => {
const a = von ? html.indexOf(`id="${von}"`) : 0
assert.ok(a >= 0, `Anker #${von}`)
const b = bis ? html.indexOf(`id="${bis}"`, a + 1) : html.length
assert.ok(b > a, `Anker #${bis}`)
return strip(html.slice(a, b))
}
test('v6-Seite: gleiche Anker wie Fassung 5, Absatzzahlen der geänderten Paragrafen', () => {
const h = lies(V6), ids = (x) => [...x.matchAll(/ id="([^"]+)"/g)].map((m) => m[1])
assert.deepEqual(ids(h), ids(lies(V5A)))
const n = (von, bis) => [...h.slice(h.indexOf(`id="${von}"`), h.indexOf(`id="${bis}"`)).matchAll(/^ <li>([\s\S]*?)<\/li>$/gm)].length
assert.deepEqual([['p1', 'p2'], ['p2', 'p3'], ['p4', 'p5'], ['p6', 'p7'], ['wechsel', 'zusatzspeicher'], ['p11', 'p12'], ['p14', 'p15']].map(([a, b]) => n(a, b)), [4, 6, 9, 6, 6, 4, 2])
assert.equal((h.slice(h.indexOf('id="p3"'), h.indexOf('id="p4"')).match(/<h3>\(\d\)/g) ?? []).length, 8)
})
test('v6-Seite: Version, Stand, Entwurfsmarke und PDF-Link passen zu termsVersionNeu', () => {
const m = /^(\d{4})-(\d{2})-(\d{2})-v6$/.exec(config.abo.termsVersionNeu); assert.ok(m)
const h = lies(V6), [, y, mo, d] = m
assert.ok(strip(h).includes(`Version 6 · Stand ${d}.${mo}.${y}`))
assert.ok(h.includes(`content: "Version 6 · Stand ${d}.${mo}.${y}"`), 'Kopfzeile im Druck')
assert.ok(!strip(h).includes('Version 5'))
assert.equal(strip(h).includes('Entwurf – Prüfung durch Kanzlei offen'), ENTWURF)
assert.ok(h.includes('href="/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf"') && !h.includes('href="/WEBklar-Abo-Vertragsunterlagen.pdf"'))
})
test('v6-Seite: jede Pflichtstelle steht im Wortlaut an ihrem Platz', () => {
const h = lies(V6)
for (const p of PFLICHT) for (const t of p.texte) assert.ok(abschnitt(h, p.von, p.bis).includes(t), `${p.stelle}: ${t.slice(0, 50)}`)
assert.ok(strip(h).includes(`Funktionen ${PFLICHT[0].texte[0]}`), 'Zeile „Funktionen“ in der Übersichtstabelle')
})
test('v6-Seite: Widerrufsbelehrung und Muster-Formular wortgleich mit Fassung 5, Abo-Preise unverändert, Funktionen ohne Preis', () => {
const h = lies(V6), v5 = lies(V5A)
const muster = (x) => between(abschnitt(x, 'widerruf', 'formular'), 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung')
assert.equal(muster(h), muster(v5))
const formular = (x) => between(abschnitt(x, 'formular', 'widerruf-hinweise'), 'Muster-Widerrufsformular', '(*) Unzutreffendes streichen.')
assert.equal(formular(h), formular(v5))
for (const s of ['20,00 €', '50,00 €', '1,00 €', '100 € pro Stunde', 'zwölf Monaten']) assert.ok(strip(h).includes(s), s)
assert.doesNotMatch(abschnitt(h, 'p3', 'p4').split('(8) Funktionen')[1], /€/)
})
test('v6-Seite: neues Aussehen, keine alten Farben, Druckkopf', () => {
const style = lies(V6).match(/<style>([\s\S]*?)<\/style>/)[1]
for (const t of ['#1a1f36', '#5e6678', '#ebeef1', '#d8dde3', '#f8f9fb', '#111', '-apple-system', 'color-scheme: light']) assert.ok(style.includes(t), t)
assert.doesNotMatch(style, /#6d1fd1|#9d3dfe|gradient|prefers-color-scheme|backdrop-filter|#f7f6f3/)
})
test('v6-Seite: Querverweise treffen (16 Paragrafen, mindestens 15 Verweise, Leistungsbeschreibung A/B Nr.)', () => {
const html = lies(V6)
const ids = [...html.matchAll(/ id="([^"]+)"/g)].map((m) => m[1])
for (const [, ref] of html.matchAll(/href="#([^"]+)"/g)) assert.equal(ids.filter((i) => i === ref).length, 1, `#${ref}`)
// Absätze je Paragraf: <li> der ol.abs auf oberster Ebene; bei § 3 die Überschriften "(M)"
const absaetze = {}
for (const [, n, body] of html.matchAll(/<h2 id="[^"]+">§ (\d+) [^<]*<\/h2>([\s\S]*?)(?=<h2 |<p class="anlage-kopf)/g)) {
const h3 = [...body.matchAll(/<h3>\((\d+)\)/g)].map((x) => Number(x[1]))
if (h3.length) { absaetze[n] = Math.max(...h3); continue }
const start = body.indexOf('<ol class="abs">')
let depth = 0, count = 0
if (start >= 0) for (const [, close, tag] of body.slice(start).matchAll(/<(\/?)(ol|ul|li)\b[^>]*>/g)) {
if (tag === 'li') { if (!close && depth === 1) count++; continue }
depth += close ? -1 : 1
if (depth === 0) break
}
absaetze[n] = count
}
assert.equal(Object.keys(absaetze).length, 16)
assert.deepEqual([absaetze[3], absaetze[4], absaetze[6], absaetze[9], absaetze[11], absaetze[14], absaetze[15]], [8, 9, 6, 6, 4, 2, 2])
const text = strip(html)
const refs = [...text.matchAll(/§ (\d+) Abs\. (\d+)/g)]
assert.ok(refs.length >= 15, `Querverweise: ${refs.length}`)
for (const [ref, n, a] of refs) assert.ok(Number(a) <= (absaetze[n] ?? 0), `${ref}: § ${n} hat ${absaetze[n] ?? 0} Absätze`)
// Leistungsbeschreibung A/B Nr. N: nummerierte <h3> der jeweiligen Beschreibung
const nummern = (fromId, toId) => [...html.slice(html.indexOf(`id="${fromId}"`), html.indexOf(`id="${toId}"`)).matchAll(/<h3>(\d+)\. /g)].map((x) => Number(x[1]))
const maxA = Math.max(...nummern('hosting', 'wartung')), maxB = Math.max(...nummern('wartung', 'widerruf'))
for (const [ref, ab, nr] of text.matchAll(/Leistungsbeschreibung ([AB]) Nr\. (\d+)/g)) assert.ok(Number(nr) <= (ab === 'A' ? maxA : maxB), ref)
})
test('v6-Seite: Entwurf ist nicht indexierbar (N45)', () => {
assert.equal(lies(V6).includes('<meta name="robots" content="noindex">'), ENTWURF)
})
+121
View File
@@ -0,0 +1,121 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Abo-Zuordnung der Funktionen (V5): aboProdukt, aboDeckt, aboLaeuftFuer. Fail-closed: Lesefehler werden weitergereicht.
const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { subscriptionDoc, wartungDoc, hostingDoc } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { ENTHALTEN_IN, aboProdukt, aboDeckt, aboLaeuftFuer } = await import('../server/services/aboFunktionen.js')
const JETZT = new Date('2026-10-17T08:00:00.000Z')
const abo = (doc) => aw.seed('subscriptions', subscriptionDoc('sub_h', doc))
const ereignis = (id, type, contractAt, over = {}) =>
aw.seed('contractEvents', { $id: id, customerId: 'c1', subscriptionId: 'sub_h', type, productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }), ...over })
// aw.docs() liefert eine Kopie: geloescht wird ueber die API
const loesche = (collection, id) => deleteDocument(collection, id)
beforeEach(async () => {
for (const collection of ['subscriptions', 'contractEvents']) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
})
const FAELLE = [ // [Name, Abo-Dokument|null, aboProdukt-Teil, aboDeckt(termine), aboDeckt(analyse)]
['kein Abo', null, { produkt: null, laeuft: false, gekuendigt: false }, 'kein_abo', 'kein_abo'],
['Wartung aktiv', wartungDoc, { produkt: 'wartung', laeuft: true, gekuendigt: false }, null, null],
['Altbestand ohne productKey', { ...wartungDoc, productKey: undefined }, { produkt: 'wartung', laeuft: true, gekuendigt: false }, null, null],
['Hosting aktiv', hostingDoc, { produkt: 'hosting', laeuft: true, gekuendigt: false }, 'falsches_abo', null],
['Wartung zum Periodenende gekündigt', { ...wartungDoc, cancelAtPeriodEnd: true }, { produkt: 'wartung', laeuft: true, gekuendigt: true }, 'gekuendigt', 'gekuendigt'],
['Wartung mit cancelAt', { ...wartungDoc, cancelAt: '2026-12-01T00:00:00.000Z' }, { produkt: 'wartung', laeuft: true, gekuendigt: true }, 'gekuendigt', 'gekuendigt'],
['Wartung past_due', { ...wartungDoc, status: 'past_due' }, { produkt: 'wartung', laeuft: true, gekuendigt: false }, 'nicht_aktiv', 'nicht_aktiv'],
['Wartung trialing ohne Probezeit-Ende', { ...wartungDoc, status: 'trialing' }, { produkt: 'wartung', laeuft: true, gekuendigt: false }, 'nicht_aktiv', 'nicht_aktiv'],
['Wartung beendet', { ...wartungDoc, status: 'canceled' }, { produkt: null, laeuft: false, gekuendigt: false }, 'kein_abo', 'kein_abo'],
]
for (const [name, doc, teil, termine, analyse] of FAELLE) {
test(`Abo-Lage: ${name}`, async () => {
if (doc) abo(doc)
assert.deepEqual(await aboProdukt('c1', { now: JETZT }), { ...teil, widerrufsfristBis: null })
assert.deepEqual(await aboDeckt('c1', 'termine'), termine ? { ok: false, grund: termine } : { ok: true })
assert.deepEqual(await aboDeckt('c1', 'analyse'), analyse ? { ok: false, grund: analyse } : { ok: true })
})
}
test('Probezeit (Privatkunde bestellte ohne Häkchen, nach_frist): beginnt_spaeter mit Datum, nicht nicht_aktiv', async () => {
const ENDE = '2026-11-03T05:00:00.000Z' // 06:00 Berlin am Tag nach dem Fristende
const probe = { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: ENDE }
abo(probe)
assert.deepEqual(await aboProdukt('c1', { now: JETZT }), { produkt: 'wartung', laeuft: true, gekuendigt: false, widerrufsfristBis: null })
for (const key of ['termine', 'analyse']) assert.deepEqual(await aboDeckt('c1', key, { now: JETZT }), { ok: false, grund: 'beginnt_spaeter', ab: ENDE }, key)
for (const trialEnd of [null, '', 'kaputt', '2026-10-17T07:59:59.000Z']) { // kein oder abgelaufenes Ende: wie bisher nicht_aktiv
abo({ ...probe, trialEnd })
assert.deepEqual(await aboDeckt('c1', 'termine', { now: JETZT }), { ok: false, grund: 'nicht_aktiv' }, String(trialEnd))
}
abo({ ...probe, cancelAtPeriodEnd: true })
assert.equal((await aboDeckt('c1', 'termine', { now: JETZT })).grund, 'gekuendigt')
abo({ ...hostingDoc, status: 'trialing', trialEnd: ENDE })
assert.equal((await aboDeckt('c1', 'termine', { now: JETZT })).grund, 'falsches_abo') // Reihenfolge: Abo vor Beginn
assert.deepEqual(await aboDeckt('c1', 'analyse', { now: JETZT }), { ok: false, grund: 'beginnt_spaeter', ab: ENDE })
})
test('aboLaeuftFuer: gekündigt und past_due zählen noch, falsches Abo und beendet nicht', async () => {
abo({ ...wartungDoc, cancelAtPeriodEnd: true })
assert.equal(await aboLaeuftFuer('c1', 'termine'), true)
abo({ ...wartungDoc, status: 'past_due' })
assert.equal(await aboLaeuftFuer('c1', 'termine'), true)
abo({ ...hostingDoc })
assert.deepEqual([await aboLaeuftFuer('c1', 'termine'), await aboLaeuftFuer('c1', 'analyse')], [false, true])
abo({ ...wartungDoc, status: 'canceled' })
assert.equal(await aboLaeuftFuer('c1', 'news'), false)
})
test('widerrufener Wechsel: Spiegel sagt Wartung, es gilt Hosting', async () => {
abo(wartungDoc)
ereignis('ev_w', 'wechsel', '2026-10-10T09:00:00.000Z', { status: 'widerrufen' })
assert.equal((await aboProdukt('c1')).produkt, 'hosting')
assert.deepEqual(await aboDeckt('c1', 'shop'), { ok: false, grund: 'falsches_abo' })
assert.equal(await aboLaeuftFuer('c1', 'shop'), false)
})
test('widerrufsfristBis: spätestes offenes Ende von Abschluss und Wechsel; Speicher, fremde und abgelaufene zählen nicht', async () => {
abo(wartungDoc)
ereignis('ev_a', 'abschluss', '2026-10-17T07:00:00.000Z') // Sa + 14 Tage = Sa 31.10. (Feiertag), So 01.11. (Feiertag) -> Mo 02.11.
ereignis('ev_w', 'wechsel', '2026-10-10T09:00:00.000Z') // -> Mo 26.10., 23:59:59.999 MEZ
ereignis('ev_s', 'speicher', '2026-10-16T09:00:00.000Z', { details: JSON.stringify({ contractAt: '2026-10-16T09:00:00.000Z', fromUnits: 0, toUnits: 1 }) })
ereignis('ev_f', 'wechsel', '2026-10-15T09:00:00.000Z', { subscriptionId: 'sub_alt', status: 'widerrufen' })
assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, '2026-11-02T22:59:59.999Z')
await loesche('contractEvents', 'ev_a')
assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, '2026-10-26T22:59:59.999Z')
ereignis('ev_alt', 'abschluss', '2026-09-20T09:00:00.000Z')
await loesche('contractEvents', 'ev_w') // Frist endete Mo 05.10.
assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, null)
})
test('widerrufsfristBis: offene Frist eines anderen Abos zählt nicht', async () => {
abo(wartungDoc)
ereignis('ev_x', 'abschluss', '2026-10-16T09:00:00.000Z', { subscriptionId: 'sub_alt' })
assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, null)
})
test('Form: ENTHALTEN_IN eingefroren und genau die Schlüssel von V5; unbekannte Funktion ist ein Programmfehler', async () => {
assert.ok(Object.isFrozen(ENTHALTEN_IN))
assert.deepEqual(Object.keys(ENTHALTEN_IN), ['analyse', 'news', 'termine', 'shop', 'unterkunft', 'zeiten'])
assert.deepEqual(ENTHALTEN_IN, { analyse: ['hosting', 'wartung'], news: ['wartung'], termine: ['wartung'], shop: ['wartung'], unterkunft: ['wartung'], zeiten: ['wartung'] })
assert.deepEqual(Object.keys(await aboProdukt('c1')).sort(), ['gekuendigt', 'laeuft', 'produkt', 'widerrufsfristBis'])
await assert.rejects(() => aboDeckt('c1', 'gibtEsNicht'), TypeError)
await assert.rejects(() => aboLaeuftFuer('c1', 'gibtEsNicht'), TypeError)
})
test('fail-closed: ein Lesefehler wird weitergereicht, nie zu kein_abo', async () => {
abo(wartungDoc)
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
await assert.rejects(() => aboDeckt('c1', 'termine'))
aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 })
await assert.rejects(() => aboProdukt('c1', { now: JETZT }))
})
+133
View File
@@ -0,0 +1,133 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
// Abo-Ende-Abgleich der Funktionen des Abo-Modells (P6 Task 8): News, Terminplaner, Webshop und Buchungskalender enden mit dem Abo
// (K39), kehren mit einem neuen Abo zurück, solange die Daten da sind. Aufbauphase (E9, N28): projekt ohne jedes Abo bleibt.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
termineConfig: schemaKeys('termineConfig'),
newsPosts: schemaKeys('newsPosts'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { customers, hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { datumKurz } = await import('../server/services/termine/zeit.js')
const { gleicheAboFunktionenAb } = await import('../server/services/aboFunktionenAbgleich.js')
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([aw.close(), st.close()])
})
const JETZT = new Date('2026-10-17T08:00:00.000Z'), TAG = 86400000, KEYS = ['news', 'termine', 'shop', 'unterkunft']
const NAMEN = { news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop', unterkunft: 'Buchungskalender' }
const seed = (key, over = {}) => aw.seed('portalFeatures', { $id: `f_p1_${key}`, customerId: 'c1', projectId: 'p1', featureKey: key, siteKey: 'abcdefghijklmnop', state: 'active', enabled: true, activatedAt: '2026-10-01T09:00:00.000Z', unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo', ...over })
const f = (key) => aw.docs('portalFeatures').get(`f_p1_${key}`)
const abo = (doc) => aw.seed('subscriptions', subscriptionDoc('sub_h', doc))
const k39 = (key) => mails.sent.find((m) => m.subject === `Deine Funktion wurde beendet: ${NAMEN[key]}`)
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht
const leere = async (...collections) => {
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei' })
beforeEach(async () => {
await leere('portalFeatures', 'subscriptions', 'contractEvents', 'termineConfig', 'newsPosts')
mails.sent.length = 0
setMailTransportForTests(mails)
aw.seed('customers', customers.c1)
})
for (const [name, doc] of [['aktiv', wartungDoc], ['zum Periodenende gekündigt', { ...wartungDoc, cancelAtPeriodEnd: true }], ['past_due', { ...wartungDoc, status: 'past_due' }]]) test(`Wartung ${name}: nichts wird abgeschaltet`, async () => {
abo(doc); KEYS.forEach((k) => seed(k)); const r = await gleicheAboFunktionenAb(JETZT)
assert.deepEqual([r.geprueft, r.abgeschaltet, r.mails, r.fehler, mails.sent.length], [4, 0, 0, 0, 0]); KEYS.forEach((k) => assert.equal(f(k).state, 'active'))
})
test('Abo beendet: alle vier abgeschaltet (setup zählt wie active), K39 je Funktion mit eigener Frist, Marker; zweiter Lauf tut nichts', async () => {
abo({ ...wartungDoc, status: 'canceled' }); KEYS.forEach((k) => seed(k)); seed('news', { state: 'setup', enabled: false, activatedAt: null })
const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.abgeschaltet, r.mails, r.fehler], [4, 4, 0])
for (const k of KEYS) {
const d = f(k); assert.deepEqual([d.state, d.enabled, d.disabledReason], ['disabled', false, 'abo_ende']); assert.ok(d.aboEndeMailAt && d.disabledAt)
const tag = datumKurz(new Date(Date.parse(d.disabledAt) + (k === 'shop' || k === 'unterkunft' ? 90 : 30) * TAG)); assert.ok(k39(k).text.includes(tag), k)
assert.equal(/CSV/.test(k39(k).text), k === 'termine'); assert.equal(/Offene Bestellungen/.test(k39(k).text), k === 'shop' || k === 'unterkunft')
}
const vorher = f('shop').disabledAt; const noch = await gleicheAboFunktionenAb(new Date(JETZT.getTime() + TAG)); assert.deepEqual([noch.abgeschaltet, noch.mails, mails.sent.length, f('shop').disabledAt], [0, 0, 4, vorher])
})
test('Hosting statt Wartung: K39 mit Hosting-Satz', async () => { abo(hostingDoc); seed('shop'); await gleicheAboFunktionenAb(JETZT); assert.match(k39('shop').text, /ein Hosting-Abo reicht dafür nicht/) })
test('Abo nicht lesbar: nichts abgeschaltet, fehler gezählt, Log ohne Adresse', async (t) => {
abo({ ...wartungDoc, status: 'canceled' }); seed('news'); const log = t.mock.method(console, 'error', () => {}); aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'k@example.invalid kaputt' })
const r = await gleicheAboFunktionenAb(JETZT); assert.ok(r.fehler >= 1); assert.equal(f('news').state, 'active'); assert.equal(mails.sent.length, 0); assert.ok(log.mock.calls.every((c) => !String(c.arguments[0]).includes('@')))
})
test('Rückkehr: nur solange die Daten da sind; ohne activatedAt zurück auf setup; ohne Mail', async () => {
abo(wartungDoc); const aus = { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: '2026-10-10T09:00:00.000Z', aboEndeMailAt: '2026-10-10T09:05:00.000Z' }
seed('news', aus); seed('termine', { ...aus, activatedAt: null }); seed('shop', aus); seed('unterkunft', aus)
const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.wiederEin, mails.sent.length], [3, 0]) // termine: keine Einstellung gespeichert → bleibt
assert.deepEqual([f('news').state, f('news').disabledReason, f('news').aboEndeMailAt, f('shop').state, f('unterkunft').state, f('termine').state], ['active', null, null, 'active', 'active', 'disabled'])
aw.seed('termineConfig', { $id: 'f_p1_termine', einstellung: '{}', version: 1 }); await gleicheAboFunktionenAb(JETZT); assert.equal(f('termine').state, 'setup') // ohne activatedAt: setup, nicht active
})
test('Rückkehr des Webshops und des Buchungskalenders nie nach der Löschung der Daten: das Dokument wird off (Task 9)', async () => {
abo(wartungDoc); const aus = { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: '2026-07-10T09:00:00.000Z', aboEndeMailAt: '2026-07-10T09:05:00.000Z' }
seed('shop', { ...aus, adminNote: '2026-10-10T09:00:00.000Z System: Shopdaten gelöscht am 2026-10-10' }); seed('unterkunft', { ...aus, adminNote: 'Buchungsdaten gelöscht am 2026-10-10' })
const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.wiederEin, r.geloescht, f('shop').state, f('unterkunft').state], [0, 2, 'off', 'off'])
})
test('Alte Dokumente bleiben unberührt; Kunde ohne Adresse setzt nur den Marker; Mailfehler: Marker leer, nächster Lauf sendet, disabledAt bleibt', async (t) => {
t.mock.method(console, 'error', () => {}); t.mock.method(console, 'warn', () => {})
abo({ ...wartungDoc, status: 'canceled' }); seed('news', { unlockedBy: 'kauf', unlockedByPurchase: true, einrichtungAbrechnung: undefined }); seed('termine', { einrichtungAbrechnung: '' }); seed('shop', { unlockedBy: 'team' })
assert.deepEqual([(await gleicheAboFunktionenAb(JETZT)).geprueft, f('news').state, f('termine').state, f('shop').state], [0, 'active', 'active', 'active'])
seed('unterkunft'); aw.seed('customers', { ...customers.c1, email: '' }); await gleicheAboFunktionenAb(JETZT); assert.deepEqual([f('unterkunft').state, Boolean(f('unterkunft').aboEndeMailAt), mails.sent.length], ['disabled', true, 0])
aw.seed('customers', customers.c1); seed('news'); setMailTransportForTests({ sendMail: async () => { throw new Error('smtp') } })
const bad = await gleicheAboFunktionenAb(JETZT); const am = f('news').disabledAt; assert.deepEqual([bad.fehler, Boolean(f('news').aboEndeMailAt)], [1, false])
setMailTransportForTests(mails); const ok = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([ok.mails, f('news').disabledAt, Boolean(f('news').aboEndeMailAt)], [1, am, true])
})
test('Projektstart-Funktionen (projekt): ohne jedes Abo unberührt (Aufbauphase), mit deckendem Abo auch, mit dem ersten nicht deckenden Abo gilt die Abo-Regel', async () => {
KEYS.forEach((k) => seed(k, { unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' }))
const leer = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([leer.abgeschaltet, leer.mails, leer.fehler, mails.sent.length], [0, 0, 0, 0]); KEYS.forEach((k) => assert.equal(f(k).state, 'active')) // kein Abo-Dokument
abo({ ...wartungDoc, status: 'incomplete' }); assert.equal((await gleicheAboFunktionenAb(JETZT)).abgeschaltet, 0) // eine abgebrochene Bestellung ist kein Abo
await leere('subscriptions'); abo(wartungDoc); assert.equal((await gleicheAboFunktionenAb(JETZT)).abgeschaltet, 0) // deckendes Abo
await leere('subscriptions'); abo(hostingDoc); const h = await gleicheAboFunktionenAb(JETZT) // das erste Abo deckt nicht: Abo-Regel
assert.deepEqual([h.abgeschaltet, h.mails], [4, 4]); KEYS.forEach((k) => assert.equal(f(k).disabledReason, 'abo_ende')); assert.match(k39('shop').text, /ein Hosting-Abo reicht dafür nicht/)
})
test('abo-Dokumente kennen keine Aufbauphase: ohne jedes Abo-Dokument werden sie wie bisher abgeschaltet', async () => {
seed('news'); const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.abgeschaltet, f('news').state], [1, 'disabled'])
})
// Task 9: News-Löschschritt und Zurücksetzen nach der Löschung
test('News: nach 30 Tagen werden Beiträge samt Bild gelöscht und das Dokument ist off; vorher und mit laufendem Abo nicht', async () => {
abo({ ...wartungDoc, status: 'canceled' }); const aus = { state: 'disabled', enabled: false, disabledReason: 'abo_ende', aboEndeMailAt: '2026-09-01T00:00:00.000Z' }
seed('news', { ...aus, disabledAt: new Date(JETZT.getTime() - 29 * TAG).toISOString() })
aw.seed('newsPosts', { $id: 'np1', customerId: 'c1', projectId: 'p1', title: 'A', imageId: null, visibleFrom: '2026-09-01T00:00:00.000Z', visibleUntil: null }); aw.seed('newsPosts', { $id: 'np2', customerId: 'c1', projectId: 'p_anders', title: 'B', imageId: null, visibleFrom: '2026-09-01T00:00:00.000Z', visibleUntil: null })
assert.equal((await gleicheAboFunktionenAb(JETZT)).geloescht, 0); assert.equal(aw.docs('newsPosts').size, 2)
await updateDocument('portalFeatures', 'f_p1_news', { disabledAt: new Date(JETZT.getTime() - 31 * TAG).toISOString() })
assert.equal((await gleicheAboFunktionenAb(JETZT)).geloescht, 1); assert.deepEqual([...aw.docs('newsPosts').keys()], ['np2'])
assert.deepEqual([f('news').state, f('news').disabledAt, f('news').aboEndeMailAt], ['off', null, null])
seed('news', { ...aus, disabledAt: new Date(JETZT.getTime() - 40 * TAG).toISOString() }); aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)) // Abo kehrt zurück: Rückkehr statt Löschung
aw.seed('newsPosts', { $id: 'np3', customerId: 'c1', projectId: 'p1', title: 'C', imageId: null, visibleFrom: '2026-09-01T00:00:00.000Z', visibleUntil: null })
await gleicheAboFunktionenAb(JETZT); assert.deepEqual([f('news').state, aw.docs('newsPosts').has('np3')], ['active', true])
})
test('News: ein Fehler beim Löschen lässt das Dokument disabled und zählt fehler', async (t) => {
abo({ ...wartungDoc, status: 'canceled' }); seed('news', { state: 'disabled', enabled: false, disabledReason: 'abo_ende', aboEndeMailAt: 'x', disabledAt: new Date(JETZT.getTime() - 31 * TAG).toISOString() })
aw.seed('newsPosts', { $id: 'np1', customerId: 'c1', projectId: 'p1', title: 'A', imageId: null, visibleFrom: '2026-09-01T00:00:00.000Z', visibleUntil: null }); t.mock.method(console, 'error', () => {}); aw.failNext({ collection: 'newsPosts', method: 'DELETE', status: 500 })
const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.fehler, f('news').state], [1, 'disabled']); assert.equal((await gleicheAboFunktionenAb(JETZT)).geloescht, 1)
})
test('Shop und Unterkunft: nach der Löschung durch die bestehenden Jobs wird das Dokument off (auch bei laufendem Abo); ohne Marker nichts', async () => {
abo(wartungDoc); const aus = { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: '2026-06-01T00:00:00.000Z', aboEndeMailAt: '2026-06-01T00:05:00.000Z' }
seed('shop', { ...aus, adminNote: 'Shopdaten gelöscht am 2026-09-01' }); seed('unterkunft', { ...aus, adminNote: 'Buchungsdaten gelöscht am 2026-09-01' }); seed('termine', { ...aus })
const r = await gleicheAboFunktionenAb(JETZT); assert.equal(r.geloescht, 2); assert.deepEqual([f('shop').state, f('unterkunft').state, f('shop').aboEndeMailAt], ['off', 'off', null])
assert.deepEqual([f('termine').state, f('shop').adminNote], ['disabled', '']) // Terminplaner: setzt termine/jobs.js selbst auf off; der Löschvermerk endet mit dem Zurücksetzen
})
test('News: ohne Marker und nach Ablauf der Frist wird gelöscht, ohne noch einmal K39 zu senden', async () => {
abo({ ...wartungDoc, status: 'canceled' }); seed('news', { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: new Date(JETZT.getTime() - 35 * TAG).toISOString(), aboEndeMailAt: null })
const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.geloescht, r.mails, f('news').state, mails.sent.length], [1, 0, 'off', 0])
})
+78
View File
@@ -0,0 +1,78 @@
import { test, after, beforeEach } 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'
// Koexistenz (P6 Task 10): Dokumente des Abo-Modells (unlockedBy UND einrichtungAbrechnung) gehören dem neuen Abgleich; der alte
// Terminplaner-Abgleich fasst sie nicht an. Zeiten koppeln auch projekt (außer in der Aufbauphase) und senden K40 und K41 ohne Preis.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
termineConfig: schemaKeys('termineConfig'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
},
datetimes: {
...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
},
defaultLimit: 25,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
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 { gleicheAboAb, endeTermineFuerKunde } = await import('../server/services/termine/aboAbgleich.js')
const { gleicheZeitenAboAb } = await import('../server/services/zeiten/aboAbgleich.js')
const mails = mailSink()
setMailTransportForTests(mails)
after(() => setMailTransportForTests(null))
seedFixtures(aw)
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht
const leere = async (...collections) => {
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
beforeEach(async () => {
await leere('portalFeatures', 'subscriptions', 'oeffnungszeiten', 'zeitenErinnerungen')
mails.sent.length = 0
})
const modell = { unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo' }
test('Terminplaner-Abgleich überspringt Abo-Modell-Dokumente in jedem Zustand, auch ohne Wartungs-Abo; Kauf-Dokumente werden weiter abgeschaltet', async () => {
// kein Abo; Abo-Modell: active, setup, disabled/abo_ende (ohne Marker); dazu ein gekaufter, aktiver Terminplaner
for (const [id, state, extra] of [['f_a', 'active', {}], ['f_s', 'setup', {}], ['f_d', 'disabled', { disabledReason: 'abo_ende', disabledAt: new Date().toISOString() }]]) aw.seed('portalFeatures', { $id: id, customerId: 'c1', projectId: `p_${id}`, featureKey: 'termine', state, enabled: state === 'active', ...modell, ...extra })
aw.seed('portalFeatures', { $id: 'f_k', customerId: 'c1', projectId: 'p_k', featureKey: 'termine', state: 'active', enabled: true, unlockedBy: 'kauf', unlockedByPurchase: true })
const r = await gleicheAboAb(new Date()); assert.deepEqual([r.abgeschaltet, r.ausgenommen], [1, 0]); assert.deepEqual(['f_a', 'f_s', 'f_d', 'f_k'].map((id) => aw.docs('portalFeatures').get(id).state), ['active', 'setup', 'disabled', 'disabled'])
assert.deepEqual((await endeTermineFuerKunde('c1')).beendet, []); assert.equal(mails.sent.filter((m) => /^Dein Terminplaner wurde beendet/.test(m.subject)).length, 1) // nur der Kauf
})
const T0 = new Date('2026-10-17T08:00:00.000Z'), TAG = 86400000 // Schritt 1 erkennt das Abo-Ende, Schritt 2 schaltet nach der 30-Tage-Frist ab (ohne gespeicherte Zeiten, also ohne Ersatz-Ticket)
const zeit = (id, over) => aw.seed('portalFeatures', { $id: id, customerId: 'c1', projectId: `p_${id}`, featureKey: 'zeiten', siteKey: `zk${id}`.padEnd(16, '2').slice(0, 16), state: 'active', enabled: true, activatedAt: '2026-10-01T09:00:00.000Z', unlockedByPurchase: false, einbau: 'angestossen', ticketId: 't1', einschaltMailAt: '2026-10-01T09:05:00.000Z', ...over })
const zdoc = (id) => aw.docs('portalFeatures').get(id)
const beendetesAbo = () => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'canceled' }))
const k40Mails = () => mails.sent.filter((m) => m.subject.startsWith('Dein Abo ist beendet – Öffnungszeiten live läuft noch bis'))
test('Zeiten: Abo-Modell (abo und projekt) bekommt K40 und K41 ohne Preis, Gratis-Zeiten ohne Marker weiter K29 und K30 mit 30 €', async () => {
beendetesAbo() // das Wartungs-Abo des Kunden ist beendet (mit einem Abo-Dokument: sonst gälte für projekt die Aufbauphase, siehe nächster Test)
zeit('z_abo', { unlockedBy: 'abo', einrichtungAbrechnung: 'abo' }); zeit('z_projekt', { unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' }); zeit('z_alt', { unlockedBy: 'abo' })
await gleicheZeitenAboAb(T0)
const k40 = k40Mails(); const k29 = mails.sent.filter((m) => m.subject.startsWith('Dein Wartungs-Abo ist beendet'))
assert.deepEqual([k40.length, k29.length], [2, 1]); assert.ok(k40.every((m) => !/€|behalten/i.test(m.text))); assert.match(k29[0].text, /30 €/)
await gleicheZeitenAboAb(new Date(T0.getTime() + 31 * TAG))
assert.deepEqual(['z_abo', 'z_projekt', 'z_alt'].map((id) => zdoc(id).disabledReason), ['abo_ende', 'abo_ende', 'abo_ende'])
const k41 = mails.sent.filter((m) => m.subject === 'Öffnungszeiten live ist abgeschaltet'); assert.equal(k41.length, 3); assert.equal(k41.filter((m) => /30 €/.test(m.text)).length, 1) // nur das Gratis-Dokument nennt noch den Preis
})
test('Zeiten, Aufbauphase (E9): projekt ohne jedes Abo bleibt unberührt, abo nicht; mit dem ersten beendeten Abo gilt die Abo-Regel auch für projekt', async () => {
zeit('z_projekt', { unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' }); zeit('z_abo', { unlockedBy: 'abo', einrichtungAbrechnung: 'abo' }) // kein Abo-Dokument
const r = await gleicheZeitenAboAb(T0); assert.equal(r.fehler, 0)
assert.deepEqual([Boolean(zdoc('z_projekt').aboEndeAt), Boolean(zdoc('z_abo').aboEndeAt), k40Mails().length], [false, true, 1]) // nur z_abo erkennt ein Abo-Ende
await gleicheZeitenAboAb(new Date(T0.getTime() + 31 * TAG)); assert.deepEqual([zdoc('z_projekt').state, zdoc('z_projekt').disabledReason ?? null], ['active', null]) // auch nach 31 Tagen
aw.seed('subscriptions', subscriptionDoc('sub_i', { ...wartungDoc, status: 'incomplete' })); await gleicheZeitenAboAb(T0); assert.equal(Boolean(zdoc('z_projekt').aboEndeAt), false) // abgebrochene Bestellung ist kein Abo
await leere('subscriptions'); beendetesAbo(); await gleicheZeitenAboAb(T0); assert.deepEqual([Boolean(zdoc('z_projekt').aboEndeAt), k40Mails().length], [true, 2])
})
+159
View File
@@ -0,0 +1,159 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.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'
// GET /api/funktionen im Abo-Modell (V5): modell, kundenart, abo, katalog und anfrageStatus je Projekt; ohne neue Ansicht unveraendert
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
featureConsents: schemaKeys('featureConsents'),
shopConfig: schemaKeys('shopConfig'),
ukEinstellungen: schemaKeys('ukEinstellungen'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
},
datetimes: {
...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
featureConsents: ['acceptedAt'],
},
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get } = await import('./support/testApp.js')
const { customerCookie, customers, kundenansichtCookie, 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 { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
const { einschaltenDeps } = await import('../server/services/zeiten/einschalten.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
after(() => setMailTransportForTests(null))
const JETZT = new Date('2026-10-17T08:00:00.000Z') // ein Samstag
const c1 = customerCookie('c1')
const vorher = {
neu: { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] },
termine: config.termine.mode,
shop: { mode: config.shop.mode, pilotEmails: [...config.shop.pilotEmails] },
zeiten: { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] },
analyse: config.analyse.enabled,
}
after(() => {
Object.assign(config.kundenansichtNeu, vorher.neu)
config.termine.mode = vorher.termine
Object.assign(config.shop, vorher.shop)
Object.assign(config.zeiten, vorher.zeiten)
config.analyse.enabled = vorher.analyse
})
const abschluss = (contractAt = JETZT.toISOString()) =>
aw.seed('contractEvents', { $id: 'ev_a', customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }) })
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht
const leere = async (...collections) => {
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
const projekt = (id, over = {}) => aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over })
beforeEach(async () => {
await leere('subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects', 'contractEvents', 'shopConfig', 'ukEinstellungen')
resetZeitenCacheForTests()
seedFixtures(aw)
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
projekt('p1')
projekt('p2')
config.kundenansichtNeu.mode = 'pilot'
config.kundenansichtNeu.pilotEmails = ['k@example.invalid', 'test@example.invalid']
config.termine.mode = 'on'
config.shop.mode = 'on'
config.zeiten.mode = 'pilot'
config.zeiten.pilotEmails = ['*']
config.analyse.enabled = false
einschaltenDeps.jetzt = () => JETZT
setMailTransportForTests(mailSink())
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
mock.method(console, 'log', () => {})
})
afterEach(() => {
mock.restoreAll()
einschaltenDeps.jetzt = () => new Date()
})
const OLD_KEYS = ['consentText', 'features', 'legal', 'projects', 'setupEstimate', 'shop', 'termine', 'unterkunft', 'ustHinweis', 'zeiten']
test('ohne neue Ansicht: Top-Level-Schlüssel unverändert, keine Anfragestatus', async () => {
config.kundenansichtNeu.mode = 'off'
const b = await get('/api/funktionen', { cookie: c1 })
assert.deepEqual(Object.keys(b).sort(), OLD_KEYS)
assert.equal(Object.hasOwn(b.projects[0].features.shop, 'anfrageStatus'), false)
config.kundenansichtNeu.mode = 'pilot'
config.kundenansichtNeu.pilotEmails = ['k2@example.invalid'] // Kunde nicht in der Liste
assert.deepEqual(Object.keys(await get('/api/funktionen', { cookie: c1 })).sort(), OLD_KEYS)
})
test('mit neuer Ansicht: Modell, Kundenart, Abo, Katalog in fester Reihenfolge', async () => {
abschluss() // offener Abschluss vom 17.10.
const b = await get('/api/funktionen', { cookie: c1 })
assert.deepEqual([b.modell, b.kundenart, b.abo], ['abo', 'privat', { produkt: 'wartung', laeuft: true, gekuendigt: false, widerrufsfristBis: '2026-11-02T22:59:59.999Z' }])
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) // Kundenart aus dem Dokument, auch in der Mitarbeiter-Kundenansicht
assert.equal((await get('/api/funktionen', { cookie: kundenansichtCookie({ customerId: 'c1' }) })).kundenart, 'unternehmen')
aw.seed('customers', customers.c1)
assert.deepEqual(b.katalog.map((e) => e.key), ['news', 'termine', 'shop', 'unterkunft', 'zeiten']) // analyse nur bei ANALYSE_ENABLED
config.analyse.enabled = true
const mit = await get('/api/funktionen', { cookie: c1 })
assert.equal(mit.katalog.at(-1).key, 'analyse')
assert.equal(mit.katalog.at(-1).enthaltenIn, 'hosting')
const t = b.katalog.find((e) => e.key === 'termine')
assert.deepEqual(Object.keys(t).sort(), ['ablauf', 'beschreibung', 'bild', 'consent', 'einrichtung', 'enthaltenIn', 'hinweis', 'key', 'name', 'nurBetrieb', 'unterlagen', 'verfuegbar'])
assert.deepEqual([t.name, t.enthaltenIn, t.ablauf, t.bild, t.nurBetrieb, t.consent.items.map((i) => i.id)], ['Terminplaner', 'wartung', 'fragebogen', '/neu/bilder/termine.webp', false, ['einschalten-zeile', 'hinweis', 'beginn']])
assert.deepEqual(t.consent.items.at(-1), { id: 'beginn', text: config.aboFunktionen.texte.beginn, haken: true, nurPrivatInFrist: true })
assert.deepEqual([t.consent.version, t.consent.knopf], [config.aboFunktionen.fassung, 'Einschalten'])
const hinweise = Object.fromEntries(b.katalog.map((e) => [e.key, e.hinweis])) // Pflichten der alten Häkchen: Server liefert den Satz, aus demselben Item wie der Nachweis
assert.deepEqual(Object.keys(hinweise).filter((k) => hinweise[k] !== null), ['termine', 'shop', 'unterkunft'])
assert.deepEqual([hinweise.shop, hinweise.termine], [b.katalog.find((e) => e.key === 'shop').consent.items[1].text, t.consent.items[1].text])
assert.match(hinweise.shop, /eigenem Namen.*eigenes Stripe-Konto/)
assert.deepEqual([hinweise.news, hinweise.zeiten, mit.katalog.at(-1).hinweis], [null, null, null])
assert.equal(mit.katalog.at(-1).consent, null)
assert.equal(b.katalog.find((e) => e.key === 'shop').nurBetrieb, true)
assert.doesNotMatch(JSON.stringify(b.katalog), /€|priceLabel/)
assert.deepEqual(t.unterlagen.leistungsbeschreibung, { version: config.termine.avvVersion, url: config.features.termine.leistungsbeschreibung.url })
assert.equal(mit.katalog.at(-1).unterlagen.avv.url, `/rechtliches/avv-website-analyse-${config.analyse.avvVersion}.html`)
config.analyse.enabled = false
})
test('verfuegbar folgt dem Schalter je Funktion; nicht verfügbare stehen trotzdem im Katalog', async () => {
config.termine.mode = 'off'
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['k2@example.invalid']
const katalog = (await get('/api/funktionen', { cookie: c1 })).katalog
const v = Object.fromEntries(katalog.map((e) => [e.key, e.verfuegbar]))
assert.deepEqual([v.news, v.termine, v.shop], [true, false, false])
assert.equal(katalog.length, 5)
})
test('anfrageStatus je Projekt für Shop und Buchungskalender', async () => {
aw.seed('portalFeatures', { $id: 'f_p1_shop', customerId: 'c1', projectId: 'p1', featureKey: 'shop', siteKey: 'abcdefghijklmnop', state: 'off', enabled: false })
aw.seed('shopConfig', { $id: 'f_p1_shop', customerId: 'c1', projectId: 'p1', siteKey: 'abcdefghijklmnop', anfrageStatus: 'offen', version: 0 })
aw.seed('portalFeatures', { $id: 'f_p1_unterkunft', customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: 'bbcdefghijklmnop', state: 'off', enabled: false })
aw.seed('ukEinstellungen', { $id: 'f_p1_unterkunft', customerId: 'c1', projectId: 'p1', anfrageStatus: 'angenommen' })
const b = await get('/api/funktionen', { cookie: c1 })
const f = (id) => b.projects.find((p) => p.projectId === id).features
assert.deepEqual([f('p1').shop.anfrageStatus, f('p1').unterkunft.anfrageStatus, f('p2').shop.anfrageStatus, f('p2').unterkunft.anfrageStatus], ['offen', 'angenommen', '', ''])
assert.equal(Object.hasOwn(f('p1').news, 'anfrageStatus'), false)
// ein Lesefehler einer Konfiguration kostet die Liste nichts: '' statt Fehler
aw.failNext({ collection: 'shopConfig', method: 'GET', status: 500 })
const c = await get('/api/funktionen', { cookie: c1 })
assert.equal(c.projects.find((p) => p.projectId === 'p1').features.shop.anfrageStatus, '')
})
+55
View File
@@ -0,0 +1,55 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Builder der Abo-Modell-Mails K38, K39, T24 (server/services/aboFunktionenMails.js) und die Zeiten-Varianten K40, K41
// (k29Mail und k30Mail mit modell: true). Die Vorlagen prüft aboFunktionenVorlagen.test.js, der Versand folgt in Task 6 und 8.
const aw = await startAppwriteMock()
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { renderMail } = await import('../server/services/mailTemplates.js')
const { anleitungsLink } = await import('../server/services/featureRegistry.js')
const { anfrageEntschiedenMail, k38Mail, k39Mail, t24Mail, loeschFristTage, kontostandText } = await import('../server/services/aboFunktionenMails.js')
const { k29Mail, k30Mail } = await import('../server/services/zeiten/mails.js')
test('k38Mail: Werte und Blöcke je Abrechnung', () => {
const m = k38Mail({ kunde: { name: 'Silke Müller' }, projekt: 'Bäckerei', key: 'termine', abrechnung: 'abo', ticketNr: '#100150', anhang: true })
assert.equal(m.template, 'K38'); assert.deepEqual(m.blocks, { stundenkonto: true, projekt_kosten: false, anhang: true })
assert.deepEqual(m.values, { kunde_name: 'Silke Müller', projekt: 'Bäckerei', funktion: 'Terminplaner', funktion_akk: 'den Terminplaner', einrichtung: 'meist in 1–2 Wochen', ticket: ' (Ticket #100150)', fassung: config.features.termine.leistungsbeschreibung.version, portal_url: `${config.publicBaseUrl}/dashboard.html#termine` })
assert.deepEqual(k38Mail({ kunde: 'K', projekt: 'P', key: 'shop', abrechnung: 'projekt' }).blocks, { stundenkonto: false, projekt_kosten: true, anhang: false })
assert.equal(k38Mail({ kunde: 'K', projekt: 'P', key: 'news', abrechnung: 'abo' }).values.ticket, '')
})
test('k39Mail: Frist, Grund und Zusatzblöcke je Funktion', () => {
const ende = new Date('2026-11-16T12:00:00.000Z')
assert.deepEqual(k39Mail({ kunde: 'K', projekt: 'P', key: 'termine', loeschdatum: ende, art: 'beendet' }).blocks, { abo_ende: true, hosting: false, termine: true, offene: false })
assert.deepEqual(k39Mail({ kunde: 'K', projekt: 'P', key: 'shop', loeschdatum: ende, art: 'hosting' }).blocks, { abo_ende: false, hosting: true, termine: false, offene: true })
assert.equal(k39Mail({ kunde: 'K', projekt: 'P', key: 'news', loeschdatum: ende, art: 'x' }).values.loeschtag, '16.11.2026')
assert.throws(() => k39Mail({ kunde: 'K', projekt: 'P', key: 'news', loeschdatum: 'morgen', art: 'x' }), TypeError)
})
test('t24Mail, loeschFristTage, kontostandText', () => {
const t = t24Mail({ kunde: 'Silke', email: 'k@example.invalid', projekt: 'P', key: 'news', customerId: 'c1', projectId: 'p1', woid: '100150', kontostand: kontostandText({ balanceMinutes: 0, vorschussMinutes: 90 }) })
assert.deepEqual([t.template, t.values.kontostand, t.blocks, t.values.woms_url], ['T24', 'Guthaben 0 min, Vorschuss 90 min', { vorschuss: true, anhang_fehlt: false }, anleitungsLink('c1', 'p1')])
assert.equal(t24Mail({ kunde: 'K', email: '', projekt: 'P', key: 'news', customerId: 'c1', projectId: 'p1', woid: '' }).blocks.vorschuss, false)
assert.deepEqual(['news', 'termine', 'shop', 'unterkunft'].map(loeschFristTage), [30, 30, 90, 90]); assert.throws(() => loeschFristTage('zeiten'), TypeError)
})
test('alle Builder-Ergebnisse rendern ohne Probleme und ohne €; Zeiten-Varianten', () => {
const bau = [k38Mail({ kunde: 'K', projekt: 'P', key: 'unterkunft', abrechnung: 'abo' }), k39Mail({ kunde: 'K', projekt: 'P', key: 'termine', loeschdatum: new Date(), art: 'x' }), t24Mail({ kunde: 'K', email: 'e', projekt: 'P', key: 'shop', customerId: 'c', projectId: 'p', woid: '1' })]
for (const m of bau) { const r = renderMail(m.template, m.values, m.blocks); assert.deepEqual(r.problems, []); assert.doesNotMatch(r.text, /€/) }
const frist = new Date('2026-11-16T12:00:00.000Z')
const k40 = k29Mail({ kunde: 'K', projekt: 'P', art: 'hosting', frist, preis: '30 €', modell: true }); assert.equal(k40.template, 'K40'); assert.deepEqual(Object.keys(k40.values).sort(), ['frist', 'kunde_name', 'portal_url', 'projekt']); assert.equal(k40.blocks.hosting, true)
const k41 = k30Mail({ kunde: 'K', projekt: 'P', loeschdatum: frist, preis: '30 €', modell: true }); assert.equal(k41.template, 'K41'); assert.equal(renderMail('K41', k41.values, k41.blocks).problems.length, 0)
assert.equal(k29Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist, preis: '30 €' }).template, 'K29') // Standard unverändert
})
test('anfrageEntschiedenMail: K42 und K43, Werte, Blöcke, Adresse; ohne Preis', () => {
const m = anfrageEntschiedenMail({ kunde: { name: 'Erika' }, projekt: 'Töpferei', key: 'shop', projectId: 'pt1', angenommen: true })
assert.equal(m.template, 'K42'); assert.deepEqual(m.blocks, { angenommen: true, abgelehnt: false })
assert.deepEqual(m.values, { kunde_name: 'Erika', projekt: 'Töpferei', begruendung: '', portal_url: `${config.publicBaseUrl}/dashboard.html#webshop?projekt=pt1` })
const u = anfrageEntschiedenMail({ kunde: { name: ' ' }, projekt: 'Donau', key: 'unterkunft', projectId: 'a b', angenommen: false, begruendung: 'Passt nicht.' })
assert.deepEqual([u.template, u.blocks, u.values.kunde_name, u.values.begruendung, u.values.portal_url], ['K43', { angenommen: false, abgelehnt: true }, 'Kunde', 'Passt nicht.', `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=a%20b`])
assert.throws(() => anfrageEntschiedenMail({ kunde: 'K', projekt: 'P', key: 'termine', projectId: 'p', angenommen: true }), TypeError)
for (const x of [m, u]) assert.deepEqual(renderMail(x.template, x.values, x.blocks).problems, [])
})
+56
View File
@@ -0,0 +1,56 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
// Tagesjob des Abo-Ende-Abgleichs (P6 Task 11): Überlappungsschutz, wirft nie, Einbindung in server/index.js vor den Löschjobs.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
termineConfig: schemaKeys('termineConfig'),
newsPosts: schemaKeys('newsPosts'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
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 { aboFunktionenTagesJob } = await import('../server/services/aboFunktionenAbgleich.js')
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([aw.close(), st.close()])
})
const JETZT = new Date('2026-10-17T08:00:00.000Z')
const seed = (key, over = {}) => aw.seed('portalFeatures', { $id: `f_p1_${key}`, customerId: 'c1', projectId: 'p1', featureKey: key, siteKey: 'abcdefghijklmnop', state: 'active', enabled: true, activatedAt: '2026-10-01T09:00:00.000Z', unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo', ...over })
const abo = (doc) => aw.seed('subscriptions', subscriptionDoc('sub_h', doc))
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei' })
beforeEach(async () => {
for (const col of ['portalFeatures', 'subscriptions', 'contractEvents', 'newsPosts']) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
mails.sent.length = 0
})
test('Überlappungsschutz: der zweite gleichzeitige Lauf wird übersprungen, danach läuft der Job wieder', async () => {
abo({ ...wartungDoc, status: 'canceled' }); seed('news')
const [a, b] = await Promise.all([aboFunktionenTagesJob(JETZT), aboFunktionenTagesJob(JETZT)])
assert.deepEqual([a, b].filter((r) => r.uebersprungen).length, 1); assert.equal([a, b].find((r) => !r.uebersprungen).abgeschaltet, 1)
assert.equal((await aboFunktionenTagesJob(JETZT)).uebersprungen, undefined)
})
test('Der Job wirft nie (Listenfehler → fehler 1) und index.js startet ihn vor den Löschjobs', async (t) => {
t.mock.method(console, 'error', () => {}); aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500 })
assert.equal((await aboFunktionenTagesJob(JETZT)).fehler, 1)
const src = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
const start = src.indexOf('setTimeout(aboFunktionenAbgleich, 150 * 1000)'); assert.ok(start > src.indexOf('const analyseAboCheck'))
assert.ok(start < src.indexOf('setTimeout(termineTagesJob, 180 * 1000)')); assert.match(src, /setInterval\(aboFunktionenAbgleich, 24 \* 60 \* 60 \* 1000\)/)
})
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { loadTemplate, renderMail, templateIds } from '../server/services/mailTemplates.js'
// P6 Task 3: Mailvorlagen K38 bis K41 und T24 des Abo-Modells (Kopien von K16 bzw. T19, du-Form, ohne Euro-Zeichen)
const SOLL = { // [Datei, Betreff, Platzhalter, Blöcke]
K38: ['K38 Funktion wird eingerichtet.html', 'Deine Funktion wird eingerichtet: {{funktion}}', ['kunde_name', 'projekt', 'funktion', 'funktion_akk', 'einrichtung', 'ticket', 'fassung', 'portal_url'], ['stundenkonto', 'projekt_kosten', 'anhang']],
K39: ['K39 Funktion beendet.html', 'Deine Funktion wurde beendet: {{funktion}}', ['kunde_name', 'projekt', 'funktion', 'loeschtag', 'portal_url'], ['abo_ende', 'hosting', 'termine', 'offene']],
K40: ['K40 Zeiten Abo-Ende.html', 'Dein Abo ist beendet – Öffnungszeiten live läuft noch bis {{frist}}', ['kunde_name', 'projekt', 'frist', 'portal_url'], ['abo_ende', 'hosting', 'wechsel_widerrufen']],
K41: ['K41 Zeiten abgeschaltet Abo.html', 'Öffnungszeiten live ist abgeschaltet', ['kunde_name', 'projekt', 'loeschdatum', 'portal_url'], []],
K42: ['K42 Webshop-Anfrage Abo.html', 'Deine Webshop-Anfrage für {{projekt}}', ['kunde_name', 'projekt', 'begruendung', 'portal_url'], ['angenommen', 'abgelehnt']],
K43: ['K43 Anfrage Buchungskalender Abo.html', 'Deine Anfrage zum Buchungskalender für {{projekt}}', ['kunde_name', 'projekt', 'begruendung', 'portal_url'], ['angenommen', 'abgelehnt']],
T24: ['T24 Funktion eingeschaltet.html', '[Abo] Funktion eingeschaltet: {{funktion}} – {{kunde_name}}', ['kunde_name', 'email', 'projekt', 'funktion', 'woid', 'kontostand', 'woms_url'], ['vorschuss', 'anhang_fehlt']],
}
const mail = (id, on) => { const t = loadTemplate(id); return renderMail(id, Object.fromEntries(t.placeholders.map((p) => [p, `W-${p}`])), Object.fromEntries(t.blocks.map((b) => [b, on]))) }
for (const [id, [file, subject, placeholders, blocks]] of Object.entries(SOLL)) test(`${id}: Datei, Betreff, Platzhalter, Blöcke; rendert ohne Reste; ohne € und ohne Sie-Form (K)`, () => {
const t = loadTemplate(id); assert.deepEqual([t.file, t.subject, t.placeholders, t.blocks], [file, subject, placeholders, blocks])
for (const on of [true, false]) { const m = mail(id, on); assert.deepEqual(m.problems, []); assert.doesNotMatch(m.html + m.text, /€/); if (id.startsWith('K')) assert.doesNotMatch(m.text, /\bSie\b|\bIhr/) }
})
test('Festtexte', () => {
assert.match(mail('K38', true).text, /Die Einrichtung geht von deinem Stundenkonto ab, auch wenn das Guthaben noch nicht reicht\. Extrakosten entstehen keine\./)
assert.match(mail('K38', true).text, /Von deinem Stundenkonto geht nichts ab\. Die Funktion läuft, solange dein Wartungs-Abo läuft\./)
assert.doesNotMatch(mail('K38', false).text, /Stundenkonto|Wartungs-Abo läuft/)
assert.match(mail('K39', true).text, /Läuft bis dahin wieder ein passendes Abo, schalten wir W-funktion automatisch und ohne Gebühr wieder ein\./)
assert.match(mail('K39', true).text, /Bitte informiere deine Gäste selbst\./); assert.doesNotMatch(mail('K39', false).text, /CSV|Offene Bestellungen/)
assert.match(mail('T24', true).text, /Das Guthaben reicht voraussichtlich nicht \(W-kontostand\)\./); assert.doesNotMatch(mail('T24', false).text, /Vorschuss/)
})
test('K42, K43: Zusage ohne Preis und ohne „bestellen“, Absage mit Begründung wie K24 und K34', () => {
const werte = { kunde_name: 'Erika', projekt: 'Töpferei', begruendung: 'Wir verkaufen keine <Waffen>.', portal_url: 'https://portal.example/dashboard.html#x?projekt=p1' }
for (const [id, satz, knopf] of [['K42', 'Du kannst den Webshop jetzt im Kundenportal einschalten; danach richten wir ihn ein.', 'Webshop einschalten'], ['K43', 'Du kannst ihn jetzt im Kundenportal einschalten; danach richten wir ihn ein.', 'Zum Kundenportal']]) {
const zu = renderMail(id, werte, { angenommen: true, abgelehnt: false }); assert.deepEqual(zu.problems, [])
assert.doesNotMatch(zu.html + zu.text, /€|UStG|bestell|Preis|Waffen/i); assert.ok(zu.text.includes(satz), id); assert.ok(zu.text.includes(knopf), id)
const ab = renderMail(id, werte, { angenommen: false, abgelehnt: true }); assert.deepEqual(ab.problems, [])
assert.match(ab.text, /leider/); assert.match(ab.text, /Wir verkaufen keine <Waffen>\./); assert.doesNotMatch(ab.text + ab.html, /€|UStG|bestell/i)
}
})
test('templateIds enthält K38 bis K43 und T24', () => { for (const id of Object.keys(SOLL)) assert.ok(templateIds().includes(id)) })
+96
View File
@@ -0,0 +1,96 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Katalogfelder, consentAbo (mit Hinweis) und die Unterlagen-Quelle im Funktionen-Register (P5, V5)
const aw = await startAppwriteMock()
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const reg = await import('../server/services/featureRegistry.js')
const { unterlagenFuer, analyseUnterlagen } = await import('../server/services/aboUnterlagen.js')
const KEYS = ['news', 'termine', 'shop', 'unterkunft', 'zeiten']
test('Reihenfolge und Felder je Funktion', () => {
assert.deepEqual(reg.katalogReihenfolge(), [...KEYS, 'analyse'])
const ablauf = { news: 'direkt', termine: 'fragebogen', shop: 'anfrage', unterkunft: 'anfrage', zeiten: 'direkt', analyse: 'direkt' }
for (const key of [...KEYS, 'analyse']) {
const f = reg.katalogFelder(key)
assert.deepEqual(Object.keys(f).sort(), ['ablauf', 'beschreibung', 'bild', 'einrichtung', 'nurBetrieb'])
assert.deepEqual([f.bild, f.ablauf, f.nurBetrieb], [`/neu/bilder/${key}.webp`, ablauf[key], key === 'shop'], key)
assert.ok(f.beschreibung.split(/(?<=[.!?])\s+/).length <= 2, 'höchstens zwei Sätze')
assert.doesNotMatch(f.beschreibung + f.einrichtung, /€|\bSie\b|\bIhre?[nmrs]?\b/, 'kein Preis, keine Sie-Form')
}
assert.equal(reg.katalogFelder('shop').einrichtung, 'meist in 2–3 Wochen')
assert.equal(reg.katalogFelder('gibtEsNicht'), null)
// die festen Werte der Tabelle
const einrichtung = { news: 'meist in 1–2 Wochen', termine: 'meist in 1–2 Wochen', shop: 'meist in 2–3 Wochen', unterkunft: 'meist in 1–2 Wochen', zeiten: 'meist in wenigen Tagen', analyse: 'meist in wenigen Tagen' }
for (const [key, text] of Object.entries(einrichtung)) assert.equal(reg.katalogFelder(key).einrichtung, text, key)
assert.equal(reg.katalogFelder('news').beschreibung, 'Zeig Neuigkeiten und Aktionen direkt auf deiner Website. Du schreibst sie im Portal, sie erscheinen sofort.')
assert.equal(reg.katalogFelder('analyse').beschreibung, 'Sieh, wie viele Besucher auf deine Website kommen und welche Seiten sie ansehen. Die Auswertung findest du direkt im Portal.')
})
const HINWEIS = { // Pflichten der alten Häkchen verantwortung, stripe, gesundheit; wörtlich wie WORTLAUT_V6.hinweis (P8)
termine: 'Der Terminplaner ist nicht für den Gesundheitsbereich gedacht. Das fragen wir im Fragebogen ab.',
shop: 'Du verkaufst in eigenem Namen und bist für deine Produkte, Preise und Rechtstexte verantwortlich. Die Zahlungen deiner Käufer laufen über dein eigenes Stripe-Konto.',
unterkunft: 'Du vermietest in eigenem Namen und bist für Preise, Stornobedingungen, Rechnungen und Meldepflichten verantwortlich. Online-Zahlungen deiner Gäste laufen über dein eigenes Stripe-Konto.',
}
test('consentAbo: Linkzeile mit Fassung, Hinweis (termine, shop, unterkunft), beginn als Entwurf, kein Häkchen gesundheit', () => {
const V = config.aboFunktionen.fassung
for (const key of KEYS) {
const c = reg.consentAbo(key)
const ids = key in HINWEIS ? ['einschalten-zeile', 'hinweis', 'beginn'] : ['einschalten-zeile', 'beginn']
assert.deepEqual([c.version, c.knopf, c.items.map((i) => i.id)], [V, 'Einschalten', ids], key)
assert.deepEqual(c.items.map((i) => i.haken), ids.map((id) => id === 'beginn'), `${key}: nur beginn ist ein Häkchen`)
assert.deepEqual([c.items.at(-1).text, c.items.at(-1).nurPrivatInFrist], [config.aboFunktionen.texte.beginn, true], key)
if (key in HINWEIS) assert.equal(c.items[1].text, HINWEIS[key], key)
}
assert.equal(config.aboFunktionen.texte.beginn, 'Bitte beginnt sofort mit der Einrichtung. Mir ist klar: Mit der vollständigen Einrichtung erlischt mein Widerrufsrecht, und wenn ich vorher widerrufe, zahle ich den bis dahin erbrachten Teil.')
assert.equal(V, '2026-10-16')
assert.equal(reg.consentAbo('termine').items[0].text, `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${config.termine.avvVersion}).`)
assert.equal(reg.consentAbo('zeiten').items[0].text, 'Es gilt die Leistungsbeschreibung (Fassung 2026-10-04).')
assert.equal(reg.consentAbo('news').items[0].text, `Es gilt die Leistungsbeschreibung (Fassung ${config.termine.avvVersion}).`)
assert.ok(!reg.consentAbo('termine').items.some((i) => i.id === 'gesundheit'), 'das Häkchen gesundheit entfällt, der Satz steht als Hinweis')
assert.doesNotMatch(JSON.stringify(Object.values(HINWEIS)), /€|\bSie\b|\bIhre?[nmrs]?\b/)
assert.equal(reg.consentAbo('analyse'), null)
assert.equal(reg.consentAbo('gibtEsNicht'), null)
})
test('consentAboAktiv und aboweg zeigen nur, was verlangt ist; das alte Register bleibt unverändert', () => {
assert.deepEqual(reg.consentAboAktiv('shop').items.map((i) => i.id), ['einschalten-zeile', 'hinweis'])
assert.deepEqual(reg.consentAboAktiv('news').items.map((i) => i.id), ['einschalten-zeile'])
assert.deepEqual(reg.consentAboAktiv('shop', { beginn: true }).items.map((i) => [i.id, i.haken]), [['einschalten-zeile', false], ['hinweis', false], ['beginn', true]])
const w = reg.aboweg(reg.featureConfig('termine'), { beginn: true })
assert.deepEqual([w.consent.version, w.widerruf, w.avv.version, w.leistungsbeschreibung.version, w.key], [config.aboFunktionen.fassung, null, config.termine.avvVersion, config.termine.avvVersion, 'termine'])
assert.deepEqual(reg.featureConfig('termine').consent.items.map((i) => i.id), ['unterlagen', 'gesundheit', 'beginn', 'information'])
assert.notEqual(reg.featureConfig('termine').widerruf, null, 'aboweg ist eine Kopie, das Register bleibt')
assert.equal(Object.keys(reg.oeffentlicheAnsicht('termine')).length, 11, 'oeffentlicheAnsicht ohne neue Felder')
})
test('unterlagenFuer und analyseUnterlagen: eine Quelle für Katalog, Nachweis und Analyse', () => {
const t = unterlagenFuer('termine')
assert.deepEqual([t.leistungsbeschreibung, t.avv], [{ version: config.termine.avvVersion, url: config.features.termine.leistungsbeschreibung.url }, { version: config.termine.avvVersion, url: config.features.termine.avv.url }])
assert.deepEqual([unterlagenFuer('news').avv, unterlagenFuer('zeiten').avv], [null, null])
assert.ok(unterlagenFuer('news').leistungsbeschreibung.version)
const a = analyseUnterlagen()
assert.deepEqual(a, { avvVersion: config.analyse.avvVersion, legal: { lb: `/rechtliches/leistungsbeschreibung-website-analyse-${config.analyse.avvVersion}.html`, avv: `/rechtliches/avv-website-analyse-${config.analyse.avvVersion}.html` } })
assert.deepEqual(unterlagenFuer('analyse'), {
leistungsbeschreibung: { version: config.analyse.avvVersion, url: a.legal.lb },
avv: { version: config.analyse.avvVersion, url: a.legal.avv },
})
assert.throws(() => unterlagenFuer('gibtEsNicht'), TypeError)
})
test('consentAboAnalyse und abowegAnalyse: Linkzeile, Zielgruppen-Häkchen und Knopf wörtlich wie in P4', () => {
const c = reg.consentAboAnalyse()
assert.deepEqual([c.version, c.avvVersion, c.knopf], [config.aboFunktionen.fassung, config.analyse.avvVersion, 'Analyse einschalten'])
assert.deepEqual(c.items, [
{ id: 'einschalten-zeile', text: `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${config.analyse.avvVersion}).`, haken: false },
{ id: 'zielgruppe', text: 'Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.', haken: true },
])
const w = reg.abowegAnalyse()
assert.deepEqual([w.key, w.leistungsbeschreibung.version, w.avv.version, w.widerruf, w.consent.knopf], ['analyse', config.analyse.avvVersion, config.analyse.avvVersion, null, 'Analyse einschalten'])
})
+50
View File
@@ -0,0 +1,50 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
const { config } = await import('../server/config.js')
const { WORTLAUT_V6, FUNKTIONEN_FASSUNG_V6, FUNKTIONEN_UNTERLAGEN_V6, mitFassung } = await import('../server/services/products.js')
test('Linkzeilen: Wortlaut, Platzhalter wird gefüllt', () => {
assert.equal(mitFassung(WORTLAUT_V6.linkzeile.bestellen, '2026-10-16-v6'), 'Es gelten die Vertragsbedingungen mit Leistungsbeschreibung, Auftragsverarbeitung und Widerrufsbelehrung (Fassung 2026-10-16-v6). Alles kommt danach auch als PDF per Mail.')
assert.equal(mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, '2026-10-16'), 'Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-16).')
assert.equal(mitFassung(WORTLAUT_V6.linkzeile.einschaltenOhneAvv, '2026-10-16'), 'Es gilt die Leistungsbeschreibung (Fassung 2026-10-16).')
assert.throws(() => mitFassung(WORTLAUT_V6.linkzeile.bestellen, ''), /Fassung/)
assert.throws(() => mitFassung('ohne Platzhalter', '2026-10-16'), /Platzhalter/)
})
test('Häkchen beginn: Wortlaut je Fall (nur privat in der Frist)', () => {
assert.deepEqual(WORTLAUT_V6.beginn, {
abo: 'Bitte beginnt sofort mit dem Abo. Mir ist klar: Widerrufe ich innerhalb von 14 Tagen, zahle ich den bis dahin erbrachten Teil.',
wechsel: 'Bitte beginnt sofort mit dem Wechsel. Mir ist klar: Widerrufe ich innerhalb von 14 Tagen, zahle ich den bis dahin erbrachten Teil.',
einrichtung: 'Bitte beginnt sofort mit der Einrichtung. Mir ist klar: Mit der vollständigen Einrichtung erlischt mein Widerrufsrecht, und wenn ich vorher widerrufe, zahle ich den bis dahin erbrachten Teil.',
})
})
test('Pflichten aus den alten Häkchen verantwortung, stripe, gesundheit', () => {
assert.deepEqual(WORTLAUT_V6.hinweis, {
shop: 'Du verkaufst in eigenem Namen und bist für deine Produkte, Preise und Rechtstexte verantwortlich. Die Zahlungen deiner Käufer laufen über dein eigenes Stripe-Konto.',
unterkunft: 'Du vermietest in eigenem Namen und bist für Preise, Stornobedingungen, Rechnungen und Meldepflichten verantwortlich. Online-Zahlungen deiner Gäste laufen über dein eigenes Stripe-Konto.',
termine: 'Der Terminplaner ist nicht für den Gesundheitsbereich gedacht. Das fragen wir im Fragebogen ab.',
})
})
test('alle Wortlaute: du-Form, kein Preis, nur {fassung} als Platzhalter, tief eingefroren', () => {
const alle = JSON.stringify(WORTLAUT_V6)
assert.doesNotMatch(alle, /€|\bSie\b|\bIhr(e[nmrs]?)?\b/)
assert.deepEqual([...alle.matchAll(/\{[a-z]+\}/g)].map((m) => m[0]).filter((x) => x !== '{fassung}'), [])
for (const t of Object.values(WORTLAUT_V6.linkzeile)) assert.equal((t.match(/\{fassung\}/g) ?? []).length, 1)
for (const o of [WORTLAUT_V6, WORTLAUT_V6.linkzeile, WORTLAUT_V6.beginn, WORTLAUT_V6.hinweis]) assert.ok(Object.isFrozen(o))
})
test('Funktionsunterlagen Fassung 6: Reihenfolge, Vorschaupfad, AVV nur wo Daten im Auftrag verarbeitet werden', () => {
assert.equal(FUNKTIONEN_FASSUNG_V6, '2026-10-16')
assert.deepEqual(Object.keys(FUNKTIONEN_UNTERLAGEN_V6), ['news', 'termine', 'shop', 'unterkunft', 'zeiten', 'analyse'])
assert.deepEqual(FUNKTIONEN_UNTERLAGEN_V6.termine.lb, { version: '2026-10-16', url: '/neu/rechtliches/leistungsbeschreibung-terminplaner-2026-10-16.html', pdfFile: 'neu/rechtliches/leistungsbeschreibung-terminplaner-2026-10-16.pdf', anhangName: 'Leistungsbeschreibung-Terminplaner-2026-10-16.pdf' })
const slugs = Object.values(FUNKTIONEN_UNTERLAGEN_V6).flatMap((u) => [u.lb, u.avv]).filter(Boolean).map((d) => d.url.replace(/^\/neu\/rechtliches\/|-2026-10-16\.html$/g, ''))
assert.deepEqual(slugs, ['leistungsbeschreibung-news', 'leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'leistungsbeschreibung-webshop', 'avv-webshop', 'leistungsbeschreibung-unterkunft', 'avv-unterkunft', 'leistungsbeschreibung-zeiten', 'leistungsbeschreibung-website-analyse', 'avv-website-analyse'])
assert.deepEqual([FUNKTIONEN_UNTERLAGEN_V6.news.avv, FUNKTIONEN_UNTERLAGEN_V6.zeiten.avv], [null, null])
assert.deepEqual(Object.values(FUNKTIONEN_UNTERLAGEN_V6).flatMap((u) => [u.lb, u.avv]).filter(Boolean).map((d) => d.anhangName.replace('-2026-10-16.pdf', '')), ['Leistungsbeschreibung-News-Pinnwand', 'Leistungsbeschreibung-Terminplaner', 'AVV-Terminplaner', 'Leistungsbeschreibung-Webshop', 'AVV-Webshop', 'Leistungsbeschreibung-Buchungskalender', 'AVV-Buchungskalender', 'Leistungsbeschreibung-Oeffnungszeiten-live', 'Leistungsbeschreibung-Website-Analyse', 'AVV-Website-Analyse'])
})
test('Verdrahtung mit P5 (läuft erst nach dem Merge von kn/server; rot, bis der Controller verdrahtet hat)', { skip: config.aboFunktionen === undefined && 'config.aboFunktionen fehlt: P5 noch nicht gemerged' }, async () => {
const reg = await import('../server/services/featureRegistry.js')
assert.equal(config.aboFunktionen.fassung, FUNKTIONEN_FASSUNG_V6)
assert.equal(config.aboFunktionen.texte.beginn, WORTLAUT_V6.beginn.einrichtung)
for (const key of ['termine', 'shop', 'unterkunft']) assert.equal(reg.consentAbo(key).items[0].text, mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, FUNKTIONEN_FASSUNG_V6), key)
for (const key of ['news', 'zeiten']) assert.equal(reg.consentAbo(key).items[0].text, mitFassung(WORTLAUT_V6.linkzeile.einschaltenOhneAvv, FUNKTIONEN_FASSUNG_V6), key)
})
+205
View File
@@ -0,0 +1,205 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
// Team-Weg der Analyse im Abo-Modell (Nachzug P6b): POST/GET /api/admin/analyse/:projectId. Kunde c1 (k@example.invalid) steht in der
// Pilotliste, c2 nicht. Im Abo-Modell reicht Hosting oder Wartung (aboDeckt), das Dokument traegt den Abo-Marker; sonst bleibt alles wie heute.
const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt', 'avvConsentDate', 'avvConsentChannel', 'removalTicketId', 'publishTicketId', 'audienceConfirmed', 'audienceTextVersion']
const CONSENT_KEYS = ['customerId', 'projectId', 'siteKey', 'event', 'avvVersion', 'via', 'acceptedAt', 'acceptedBy', 'textformDate', 'textformChannel', 'audienceConfirmed', 'audienceTextVersion']
const aw = await startAppwriteMock({
schema: {
portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS],
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
featureConsents: schemaKeys('featureConsents'),
analyseConsents: CONSENT_KEYS,
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, rawGet, post } = await import('./support/testApp.js')
const { hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { default: adminAnalyseRoutes } = await import('../server/routes/admin/analyse.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { istAboModell } = await import('../server/services/aboModell.js')
const { gleicheAboFunktionenAb } = await import('../server/services/aboFunktionenAbgleich.js')
const { analyseUnterlagen } = await import('../server/services/aboUnterlagen.js')
const an = await import('../server/services/analyse.js')
const sf = await import('../server/services/siteFeatures.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const app = await startTestApp({ '/api/admin/analyse': adminAnalyseRoutes })
setMailTransportForTests(mailSink())
const vorher = { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails], analyse: config.analyse.enabled }
after(() => {
Object.assign(config.kundenansichtNeu, { mode: vorher.mode, pilotEmails: vorher.pilotEmails })
config.analyse.enabled = vorher.analyse
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
const putSiteAufrufe = []
an.analyseDeps.client = {
putSite: async (siteKey, body) => putSiteAufrufe.push({ siteKey, ...body }),
stopSite: async () => {},
getStats: async () => ({ pv: 5 }),
heatToken: () => 'token',
}
an.analyseDeps.rebuild = async () => {}
const recordConsentOriginal = an.analyseDeps.recordConsent
const JETZT = new Date()
const todayBerlin = () => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date())
const hdr = { headers: { Authorization: 'Bearer jwt-admin' } }
const tf = (extra = {}) => ({ textformDate: todayBerlin(), textformChannel: 'chat', audienceConfirmed: true, ...extra })
const einschalten = (id, extra = {}) => post(`/api/admin/analyse/${id}`, { on: true, ...tf(), ...extra }, hdr)
const lesen = (id) => rawGet(`/api/admin/analyse/${id}`, hdr)
const abo = (doc) => aw.seed('subscriptions', subscriptionDoc('sub_h', doc))
const featureDoc = (c, p) => sf.getFeatureDoc(c, p, 'analyse')
const consents = () => [...aw.docs('analyseConsents').values()]
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht
const leere = async (...collections) => {
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
const nichtsGeschrieben = async (c, p) => {
assert.equal(await featureDoc(c, p), null, 'kein Dokument')
assert.deepEqual([putSiteAufrufe.length, consents().length, aw.docs('workorders').size], [0, 0, 0])
}
beforeEach(async () => {
putSiteAufrufe.length = 0
await leere('portalFeatures', 'featureConsents', 'analyseConsents', 'subscriptions', 'websiteProjects', 'contractEvents', 'workorders')
seedFixtures(aw) // c1 mit Hosting-Abo sub_h, c2 mit sub_h2
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', liveDomain: 'baeckerei.de', isPublic: true })
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Privat', subdomain: 'privat', isPublic: false })
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c2', projectName: 'Café', subdomain: 'cafe', isPublic: true })
config.kundenansichtNeu.mode = 'pilot'
config.kundenansichtNeu.pilotEmails = ['k@example.invalid']
config.analyse.enabled = true
an.analyseDeps.recordConsent = recordConsentOriginal
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
mock.method(console, 'log', () => {})
})
afterEach(() => mock.restoreAll())
test('Hosting-Kunde im Abo-Modell: 200, Analyse aktiv, Abo-Marker am Dokument, Nachweis der Textform, Fassung der Abo-Unterlagen', async () => {
const r = await einschalten('p1')
assert.deepEqual([r.status, r.body], [200, { ok: true, state: 'active' }])
const d = await featureDoc('c1', 'p1')
assert.deepEqual([d.state, d.unlockedBy, d.einrichtungAbrechnung, istAboModell(d)], ['active', 'abo', 'abo', true])
assert.deepEqual([d.avvConsentDate, d.avvConsentChannel, d.audienceConfirmed, d.avvVersion], [todayBerlin(), 'chat', true, analyseUnterlagen().avvVersion])
assert.deepEqual([putSiteAufrufe.length, putSiteAufrufe[0].active], [1, true])
const [n] = consents()
assert.deepEqual([consents().length, n.via, n.textformDate, n.textformChannel, n.audienceConfirmed, n.avvVersion], [1, 'woms', todayBerlin(), 'chat', true, analyseUnterlagen().avvVersion])
// das Team beendet wieder: ohne Abo-Pruefung
await leere('subscriptions')
const aus = await post('/api/admin/analyse/p1', { on: false }, hdr)
assert.deepEqual([aus.status, aus.body], [200, { ok: true, state: 'off' }])
})
test('Wartungs-Kunde im Abo-Modell zählt auch; die Tagesprüfung und der Abo-Ende-Abgleich lassen das Dokument in Ruhe', async () => {
await leere('subscriptions')
abo(wartungDoc)
assert.equal((await einschalten('p1')).status, 200)
assert.equal(istAboModell(await featureDoc('c1', 'p1')), true)
await leere('subscriptions')
abo(hostingDoc)
const tag = await an.runAboCheck({ now: JETZT })
assert.deepEqual(tag.deactivated, [])
const abgleich = await gleicheAboFunktionenAb(JETZT)
assert.deepEqual([abgleich.abgeschaltet, abgleich.fehler], [0, 0])
assert.equal((await featureDoc('c1', 'p1')).state, 'active')
})
test('Kunde ohne Abo-Modell und nur Hosting: wie heute abgewiesen (403, Wartungs-Text), nichts geschrieben; Modus off ebenso', async () => {
const alt = 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.'
const r = await einschalten('p3') // c2 steht nicht in der Pilotliste
assert.deepEqual([r.status, r.body], [403, { error: alt }])
await nichtsGeschrieben('c2', 'p3')
config.kundenansichtNeu.mode = 'off'
const aus = await einschalten('p1')
assert.deepEqual([aus.status, aus.body], [403, { error: alt }])
await nichtsGeschrieben('c1', 'p1')
const g = await lesen('p3')
assert.deepEqual([g.status, g.body.eligible, g.body.reasonText], [200, false, alt])
})
test('Kunde ohne Abo-Modell mit Wartungs-Abo: wie heute, ohne Marker', async () => {
await leere('subscriptions')
aw.seed('subscriptions', subscriptionDoc('sub_w2', { ...wartungDoc, customerId: 'c2', email: 'k2@example.invalid' }))
const r = await einschalten('p3')
assert.deepEqual([r.status, r.body], [200, { ok: true, state: 'active' }])
const d = await featureDoc('c2', 'p3')
assert.deepEqual([d.state, d.unlockedBy ?? null, d.einrichtungAbrechnung ?? null, istAboModell(d)], ['active', null, null, false])
assert.equal(d.avvVersion, config.analyse.avvVersion)
})
test('Abo-Modell ohne deckendes Abo: 409 abo_fehlt mit Grund, nichts geschrieben', async () => {
const faelle = [
[null, 'kein_abo', undefined],
[{ ...hostingDoc, status: 'canceled' }, 'kein_abo', undefined],
[{ ...hostingDoc, cancelAtPeriodEnd: true }, 'gekuendigt', undefined],
[{ ...hostingDoc, status: 'past_due' }, 'nicht_aktiv', undefined],
[{ ...wartungDoc, status: 'trialing', trialEnd: '2099-01-01T05:00:00.000Z' }, 'beginnt_spaeter', '2099-01-01T05:00:00.000Z'],
]
for (const [doc, grund, ab] of faelle) {
await leere('subscriptions')
if (doc) abo(doc)
const r = await einschalten('p1')
assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel, r.body.ab, typeof r.body.error], [409, 'abo_fehlt', grund, false, ab, 'string'], grund)
await nichtsGeschrieben('c1', 'p1')
}
})
test('Abo nicht lesbar: 503 statt Einschalten (fail-closed), nichts geschrieben; danach klappt es', async () => {
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
const r = await einschalten('p1')
assert.deepEqual([r.status, r.body.code, typeof r.body.error], [503, undefined, 'string'])
await nichtsGeschrieben('c1', 'p1')
assert.equal((await einschalten('p1')).status, 200)
})
test('Reihenfolge: ungültige Angaben (400) und fehlendes Projekt (404) kommen vor abo_fehlt; nicht öffentliches Projekt ist 409 ohne Code', async () => {
await leere('subscriptions')
assert.equal((await einschalten('p1', { audienceConfirmed: undefined })).status, 400)
assert.equal((await einschalten('p1', { textformChannel: 'telefon' })).status, 400)
assert.equal((await einschalten('nix')).status, 404)
await nichtsGeschrieben('c1', 'p1')
abo(hostingDoc)
const privat = await einschalten('p2')
assert.deepEqual([privat.status, privat.body.code], [409, undefined])
await nichtsGeschrieben('c1', 'p2')
})
test('Misslingt der Nachweis, nimmt der Server alles zurück: Dokument aus, kein Marker', async () => {
an.analyseDeps.recordConsent = async () => {
throw new Error('Nachweis nicht gespeichert')
}
const r = await einschalten('p1')
assert.equal(r.status, 500)
const d = await featureDoc('c1', 'p1')
assert.deepEqual([d.state, d.unlockedBy ?? null, d.einrichtungAbrechnung ?? null], ['off', null, null])
assert.deepEqual(putSiteAufrufe.map((a) => a.active), [true, false])
an.analyseDeps.recordConsent = recordConsentOriginal
assert.equal((await einschalten('p1')).status, 200) // erneut versuchbar
assert.equal(istAboModell(await featureDoc('c1', 'p1')), true)
})
test('GET: Eignung und Text folgen dem Abo-Modell (Hosting ok, ohne Abo der Hosting-oder-Wartungs-Text), Rest unverändert', async () => {
const ok = await lesen('p1')
assert.deepEqual([ok.status, ok.body.eligible, ok.body.reasonText, ok.body.state, ok.body.enabled, ok.body.avvVersion], [200, true, null, null, true, config.analyse.avvVersion])
assert.deepEqual(Object.keys(ok.body).sort(), ['accentColor', 'activatedAt', 'avvAcceptedAt', 'avvAcceptedBy', 'avvConsentChannel', 'avvConsentDate', 'avvVersion', 'eligible', 'enabled', 'offSince', 'privacy', 'reasonText', 'state'].sort())
const privat = await lesen('p2')
assert.deepEqual([privat.body.eligible, privat.body.reasonText], [false, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich])
assert.equal((await einschalten('p1')).status, 200)
assert.equal((await lesen('p1')).body.state, 'active')
await leere('subscriptions')
const ohne = await lesen('p1')
assert.deepEqual([ohne.body.eligible, ohne.body.reasonText], [false, 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.'])
})
+184
View File
@@ -0,0 +1,184 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
// Analyse im Abo-Modell (P5 Task 13): Hosting reicht, die Zielgruppen-Aussage ist serverseitig erzwungen, der Nachweis liegt in
// featureConsents, die Tagesprüfung lässt Hosting-Analysen der neuen Ansicht in Ruhe. Die alte Ansicht bleibt unverändert.
const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt', 'avvConsentDate', 'avvConsentChannel', 'removalTicketId', 'publishTicketId', 'audienceConfirmed', 'audienceTextVersion']
const CONSENT_KEYS = ['customerId', 'projectId', 'siteKey', 'event', 'avvVersion', 'via', 'acceptedAt', 'acceptedBy', 'textformDate', 'textformChannel', 'audienceConfirmed', 'audienceTextVersion']
const aw = await startAppwriteMock({
schema: {
portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS],
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
featureConsents: schemaKeys('featureConsents'),
analyseConsents: CONSENT_KEYS,
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post } = await import('./support/testApp.js')
const { customerCookie, customers, hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const an = await import('../server/services/analyse.js')
const { analyseUnterlagen } = await import('../server/services/aboUnterlagen.js')
const { aboModellFuerKunde } = await import('../server/services/aboFunktionen.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
setMailTransportForTests(mailSink())
const vorher = {
neu: { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] },
analyse: config.analyse.enabled,
}
after(() => {
Object.assign(config.kundenansichtNeu, vorher.neu)
config.analyse.enabled = vorher.analyse
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
const putSiteAufrufe = []
an.analyseDeps.client = {
putSite: async (siteKey, body) => putSiteAufrufe.push({ siteKey, ...body }),
stopSite: async () => {},
getStats: async () => ({ pv: 5 }),
heatToken: () => 'token',
}
an.analyseDeps.rebuild = async () => {}
config.analyse.enabled = true
const JETZT = new Date('2026-10-17T08:00:00.000Z')
const c1 = customerCookie('c1')
const abo = (doc) => aw.seed('subscriptions', subscriptionDoc('sub_h', doc))
const aktiveAnalyse = (c, p) => aw.seed('portalFeatures', { $id: `f_${p}_analyse`, customerId: c, projectId: p, featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' })
const consent = () => ({ accepted: true, avvVersion: analyseUnterlagen().avvVersion })
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht
const leere = async (...collections) => {
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
beforeEach(async () => {
putSiteAufrufe.length = 0
await leere('portalFeatures', 'featureConsents', 'analyseConsents', 'subscriptions', 'websiteProjects', 'contractEvents', 'workorders')
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', liveDomain: 'baeckerei.de', isPublic: true })
config.kundenansichtNeu.mode = 'pilot'
config.kundenansichtNeu.pilotEmails = ['k@example.invalid']
config.analyse.enabled = true
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
mock.method(console, 'log', () => {})
})
afterEach(() => mock.restoreAll())
test('Eignung: Hosting zählt nur im Abo-Modell; gekündigt und kein Abo nicht', async () => {
abo(hostingDoc)
assert.deepEqual(await an.analyseEligibility('c1', 'p1'), { ok: false, reason: 'kein_abo' }) // alte Regel
const neu = await an.analyseEligibility('c1', 'p1', { aboModell: true })
assert.deepEqual([neu.ok, neu.project.$id], [true, 'p1'])
abo({ ...hostingDoc, cancelAtPeriodEnd: true })
assert.deepEqual(await an.analyseEligibility('c1', 'p1', { aboModell: true }), { ok: false, reason: 'kein_abo' })
await leere('subscriptions')
assert.equal((await an.analyseEligibility('c1', 'p1', { aboModell: true })).reason, 'kein_abo')
abo({ ...wartungDoc, status: 'trialing', trialEnd: '2099-01-01T05:00:00.000Z' }) // Probezeit: die Analyse-Eignung meldet weiter kein_abo
assert.equal((await an.analyseEligibility('c1', 'p1', { aboModell: true })).reason, 'kein_abo')
assert.equal(an.eligibilityMessage('kein_abo', { aboModell: true }), 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.')
assert.equal(an.eligibilityMessage('kein_abo'), 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.')
assert.equal(an.eligibilityMessage('nicht_oeffentlich', { aboModell: true }), an.ELIGIBILITY_MESSAGES.nicht_oeffentlich)
assert.equal(an.ELIGIBILITY_MESSAGES_ABO.kein_abo, 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.')
})
const aktivieren = (extra = {}, cookie = c1) => post('/api/funktionen/analyse/activate', { projectId: 'p1', consent: consent(), audienceConfirmed: true, ...extra }, { cookie })
const keinSchreiben = () => assert.deepEqual([aw.docs('featureConsents').size, putSiteAufrufe.length, aw.docs('portalFeatures').get('f_p1_analyse')?.state ?? 'off'], [0, 0, 'off'])
test('Liste und Aktivieren über die Routen: neue Ansicht mit Hosting ja, alte Ansicht nein', async () => {
abo(hostingDoc)
const b = await get('/api/funktionen', { cookie: c1 })
const a = b.projects.find((p) => p.projectId === 'p1').analyse
assert.deepEqual([a.eligible, a.reason, a.avvVersion, a.legal], [true, null, analyseUnterlagen().avvVersion, analyseUnterlagen().legal]) // Ansicht zeigt die Fassung, die der Server prüft
assert.equal((await aktivieren()).status, 200)
config.kundenansichtNeu.mode = 'off'
const alt = (await get('/api/funktionen', { cookie: c1 })).projects.find((p) => p.projectId === 'p1').analyse
assert.deepEqual([alt.eligible, alt.reasonText], [false, 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.'])
})
test('Zielgruppe serverseitig erzwungen: ohne audienceConfirmed true 400 zielgruppe_fehlt, nichts geschrieben', async () => {
abo(hostingDoc)
for (const audienceConfirmed of [undefined, false, 'true', 1, null]) {
const r = await aktivieren({ audienceConfirmed })
assert.deepEqual([r.status, r.body], [400, { error: 'Bitte bestätige, dass sich deine Website nicht gezielt an Kinder unter 16 Jahren richtet.', code: 'zielgruppe_fehlt' }], String(audienceConfirmed))
keinSchreiben()
}
assert.equal((await aktivieren({ consent: { accepted: true, avvVersion: '1999-01-01' } })).status, 400) // falsche Fassung: weiter abgelehnt, nichts geschrieben
keinSchreiben()
})
test('Nachweis der Aktivierung: Linkzeile, Zielgruppen-Zeile, Knopf, Fassungen und die Kundenart des Dokuments', async () => {
abo(hostingDoc)
for (const [kundenart, soll] of [['unternehmen', 'unternehmen'], ['privat', 'privat'], [undefined, 'privat'], ['kaputt', 'privat']]) {
await leere('portalFeatures', 'featureConsents')
aw.seed('customers', { ...customers.c1, ...(kundenart === undefined ? {} : { kundenart }) })
const r = await aktivieren({ kundenart: 'unternehmen', unternehmer: true })
assert.equal(r.status, 200, String(kundenart)) // Body-Werte zur Kundenart zählen nicht
const [n] = [...aw.docs('featureConsents').values()]
assert.equal(aw.docs('featureConsents').size, 1)
assert.deepEqual([n.featureKey, n.projectId, n.customerId, n.kundenart, n.stripeSessionId], ['analyse', 'p1', 'c1', soll, ''], String(kundenart))
assert.deepEqual(JSON.parse(n.texte), [
{ id: 'einschalten-zeile', text: `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${analyseUnterlagen().avvVersion}).`, haken: false },
{ id: 'zielgruppe', text: 'Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.', haken: true },
{ id: 'knopf', text: 'Analyse einschalten', haken: false },
])
assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: config.aboFunktionen.fassung, leistungsbeschreibung: analyseUnterlagen().avvVersion, avv: analyseUnterlagen().avvVersion })
assert.equal(aw.docs('portalFeatures').get('f_p1_analyse').audienceConfirmed, true)
}
assert.equal((await aktivieren({ accentColor: '#222222' })).status, 200)
assert.equal(aw.docs('featureConsents').size, 1) // nur die Farbe einer aktiven Analyse: kein zweiter Nachweis
})
test('Nachweis scheitert: alles zurückgenommen (kein on ohne Nachweis), der nächste Versuch klappt', async () => {
abo(hostingDoc)
aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 })
const r = await aktivieren()
assert.equal(r.status, 500)
assert.deepEqual([aw.docs('featureConsents').size, aw.docs('portalFeatures').get('f_p1_analyse').state, putSiteAufrufe.map((a) => a.active)], [0, 'off', [true, false]]) // Messdienst angemeldet und wieder zurückgenommen
assert.equal((await aktivieren()).status, 200)
assert.equal(aw.docs('featureConsents').size, 1)
})
test('alte Ansicht unverändert: ohne audienceConfirmed 200, kein Eintrag in featureConsents', async () => {
config.kundenansichtNeu.mode = 'off'
abo({ ...wartungDoc })
assert.equal((await aktivieren({ audienceConfirmed: undefined })).status, 200)
assert.equal(aw.docs('featureConsents').size, 0)
})
test('Tagesprüfung: Hosting-Analyse bleibt für Kunden der neuen Ansicht, sonst endet sie wie bisher (K19)', async () => {
const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails })
const lauf = async () => {
aktiveAnalyse('c1', 'p1')
return (await an.runAboCheck({ now: JETZT })).deactivated.length
} // Kunde c1: k@example.invalid
abo(hostingDoc)
for (const [mode, liste, deaktiviert] of [['on', [], 0], ['pilot', ['k@example.invalid'], 0], ['pilot', ['*'], 0], ['pilot', ['k2@example.invalid'], 1], ['pilot', [], 1], ['off', ['*'], 1]]) {
stelle(mode, liste)
assert.equal(await lauf(), deaktiviert, `${mode} ${liste}`)
}
aw.seed('customers', { ...customers.c1, email: ' K@Example.Invalid ' })
stelle('pilot', ['k@example.invalid'])
assert.equal(await lauf(), 0, 'E-Mail des Kunden mit Großbuchstaben und Leerzeichen trifft die Liste')
aw.seed('customers', customers.c1)
stelle('on')
abo({ ...wartungDoc, cancelAtPeriodEnd: true })
assert.equal(await lauf(), 0, 'gekündigt, aber noch laufend')
abo({ ...wartungDoc, status: 'canceled' })
assert.equal(await lauf(), 1, 'beendet')
assert.equal(await aboModellFuerKunde('gibtEsNicht'), false) // kein Kundendokument: kein Abo-Modell
assert.equal(await aboModellFuerKunde(''), false)
})
+88
View File
@@ -0,0 +1,88 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Einrichtungs-Ticket mit Abrechnung (V7) und unlockedBy in featureView fuer alle Funktionen.
// workorders, websiteProjects und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau.
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { createFeatureTicket, ABRECHNUNGS_ZEILEN } = await import('../server/services/ticketAdmin.js')
const { featureView, ticketNachlegen } = await import('../server/services/siteFeatures.js')
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin', email: 'kunde1@example.test' })
const ALT = ['createdAt', 'customerId', 'customerLocation', 'customerName', 'details', 'priority', 'serviceType', 'status', 'systemType', 'topic', 'type', 'woid']
const base = { customerId: 'c1', project: { $id: 'p1', projectName: 'Bäckerei' }, featureKey: 'termine', siteKey: 'abcdefghijklmnop', topic: 'T', details: 'Zeile 1' }
const nutzer = (doc) => Object.keys(doc).filter((k) => !k.startsWith('$')).sort()
// aw.docs() liefert eine Kopie des Standes zum Aufruf: das Ticket erst anlegen, dann nachsehen
const ticketVon = async (args) => {
const { ticketId } = await createFeatureTicket(args)
return aw.docs('workorders').get(ticketId)
}
test('abrechnung abo und projekt: Felder und letzte Zeile', async () => {
assert.deepEqual(ABRECHNUNGS_ZEILEN, {
abo: 'Zeit im Arbeitsblatt erfassen und aufs Abo-Stundenkonto anrechnen (Einrichtung aus dem Abo, Vorschuss möglich).',
projekt: 'Zeit im Arbeitsblatt erfassen, nicht aufs Abo anrechnen (im Projekt enthalten).',
})
const t = await ticketVon({ ...base, abrechnung: 'abo' })
assert.deepEqual([t.einrichtungFuer, t.einrichtungAbrechnung], ['termine', 'abo'])
assert.equal(t.details, `Zeile 1\n${ABRECHNUNGS_ZEILEN.abo}`)
assert.ok((await ticketVon({ ...base, abrechnung: 'projekt' })).details.endsWith('\nZeit im Arbeitsblatt erfassen, nicht aufs Abo anrechnen (im Projekt enthalten).'))
})
test('ohne abrechnung: keine neuen Felder, Details unverändert (alte Ansicht)', async () => {
for (const abrechnung of [undefined, '']) {
const t = await ticketVon({ ...base, abrechnung })
assert.deepEqual(nutzer(t), ALT)
assert.equal(t.details, 'Zeile 1')
}
})
test('ungültiger Wert: TypeError, kein Ticket', async () => {
const n = aw.docs('workorders').size
for (const abrechnung of ['frei', 'Abo', null, 5]) await assert.rejects(() => createFeatureTicket({ ...base, abrechnung }), TypeError, String(abrechnung))
assert.equal(aw.docs('workorders').size, n)
})
test('lange Details: gekürzt auf 7000, die Schlusszeile bleibt; auch im Notfallweg nach 400', async () => {
const lang = Array.from({ length: 400 }, (_, i) => `Zeile ${i} ${'x'.repeat(30)}`).join('\n')
const t = await ticketVon({ ...base, details: lang, abrechnung: 'abo' })
assert.ok(t.details.length <= 7000 && t.details.endsWith(ABRECHNUNGS_ZEILEN.abo))
assert.ok(t.details.length > 6800, 'es wird nicht zu stark gekürzt')
aw.failNext({ collection: 'workorders', method: 'POST', status: 400, message: 'zu lang' })
const n = await ticketVon({ ...base, details: lang, fallbackDetails: 'Notfall', abrechnung: 'projekt' })
assert.ok(n.details.startsWith('Notfall') && n.details.endsWith(ABRECHNUNGS_ZEILEN.projekt))
})
test('featureView: unlockedBy für alle Funktionen, aboEndeAt/einbau nur bei Zeiten', async () => {
assert.equal((await featureView({ state: 'setup', unlockedBy: 'abo' }, 'news')).unlockedBy, 'abo')
assert.equal((await featureView({ state: 'setup', unlockedBy: 'projekt' }, 'shop')).unlockedBy, 'projekt')
assert.equal((await featureView({ state: 'active', unlockedByPurchase: true }, 'termine')).unlockedBy, 'kauf')
assert.equal((await featureView(undefined, 'news')).unlockedBy, null)
assert.equal((await featureView({ state: 'off', unlockedBy: 'abo' }, 'news')).unlockedBy, null)
assert.equal(Object.hasOwn(await featureView({ state: 'setup', unlockedBy: 'abo' }, 'news'), 'einbau'), false)
assert.equal(Object.hasOwn(await featureView({ state: 'setup', unlockedBy: 'abo' }, 'news'), 'aboEndeAt'), false)
const zeiten = await featureView({ state: 'setup', unlockedBy: 'abo', einbau: 'angestossen' }, 'zeiten')
assert.deepEqual([zeiten.unlockedBy, zeiten.einbau, Object.hasOwn(zeiten, 'aboEndeAt')], ['abo', 'angestossen', true])
})
test('ersetztes Ticket trägt die Abrechnung des Dokuments', async () => {
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei' })
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false, unlockedBy: 'abo', einrichtungAbrechnung: 'abo', ticketId: 'weg' })
const { doc } = await ticketNachlegen({ customerId: 'c1', projectId: 'p1', key: 'news', by: 'Kenso' })
const ticket = aw.docs('workorders').get(doc.ticketId)
assert.deepEqual([ticket.einrichtungAbrechnung, ticket.einrichtungFuer], ['abo', 'news'])
assert.ok(ticket.details.endsWith(ABRECHNUNGS_ZEILEN.abo))
})
test('ersetztes Ticket ohne Abrechnung am Dokument: wie bisher, keine neuen Felder', async () => {
aw.seed('portalFeatures', { $id: 'f_p1_termine', customerId: 'c1', projectId: 'p1', featureKey: 'termine', siteKey: 'bbcdefghijklmnop', state: 'setup', enabled: false, unlockedByPurchase: true, ticketId: 'weg2' })
const { doc } = await ticketNachlegen({ customerId: 'c1', projectId: 'p1', key: 'termine', by: 'Kenso' })
assert.deepEqual(nutzer(aw.docs('workorders').get(doc.ticketId)), ALT)
})
+45
View File
@@ -0,0 +1,45 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Ticket-Texte zum Stundenkonto je Abrechnung (P6 Task 14): Dokumente des Abo-Modells (einrichtungAbrechnung abo oder projekt) sagen im
// Ticket nirgends "nicht anrechnen"; die Abrechnung steht allein in der Schlusszeile aus createFeatureTicket (ABRECHNUNGS_ZEILEN).
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
after(() => Promise.all([aw.close(), st.close()]))
const { termineTicketDetails } = await import('../server/services/termine/ticketDetails.js')
const { shopTicketDetails } = await import('../server/services/shop/ticketDetails.js')
const { unterkunftTicketDetails } = await import('../server/services/unterkunft/ticketDetails.js')
const { einbauanleitung } = await import('../server/services/termine/einbauanleitung.js')
const { ausVorlage } = await import('../server/services/termine/vorlagen.js')
const NOW = new Date('2026-10-17T08:00:00.000Z')
// Salon-Vorlage als gespeicherte Einstellung: erst damit erzeugt der Generator alle Abschnitte (Checkliste, Abrechnung), ohne Einstellung nur das Snippet
const EINSTELLUNG = () => ausVorlage('salon', { name: 'Salon Muster', email: 'inhaber@salon-muster.example', telefon: '0631 123456', adresse: 'Hauptstraße 1, 67655 Kaiserslautern', kleinunternehmer: false, impressumUrl: 'https://salon-muster.example/impressum', datenschutzUrl: 'https://salon-muster.example/datenschutz', bundesland: 'RP' }, NOW)
const ALT = /nicht auf (das )?Stundenkontingent|nicht auf Abo-Stunden|angerechnet/i
const ctx = (key, doc) => ({ feature: { key }, project: { projectName: 'Bäckerei', subdomain: 'baeckerei' }, customer: { name: 'Kunde' }, doc: { $id: `f_${key}`, siteKey: 'abcdefghijklmnop', customerId: 'c1', projectId: 'p1', featureKey: key, ...doc } })
const BAUER = { termine: termineTicketDetails, shop: shopTicketDetails, unterkunft: unterkunftTicketDetails }
for (const [key, bau] of Object.entries(BAUER)) {
test(`${key}: mit Abrechnung abo und projekt keine „nicht anrechnen“-Aussage im Ticket`, async () => {
for (const einrichtungAbrechnung of ['abo', 'projekt']) assert.doesNotMatch(await bau(ctx(key, { einrichtungAbrechnung, unlockedBy: einrichtungAbrechnung })), ALT, `${key} ${einrichtungAbrechnung}`)
})
test(`${key}: Kauf-Bestand und Altdokumente behalten ihren Wortlaut`, async () => { assert.match(await bau(ctx(key, key === 'termine' ? { unlockedByPurchase: true } : {})), ALT) })
}
test('Terminplaner-Anleitung: Text und Kurzfassung je Abrechnung; Notfalltext ohne Kontingent-Satz', async (t) => {
t.mock.method(console, 'error', () => {})
const gen = (feature) => einbauanleitung({ feature: { $id: 'f1', siteKey: 'abcdefghijklmnop', customerId: 'c1', projectId: 'p1', ...feature }, project: { projectName: 'B' }, einstellung: EINSTELLUNG(), fragebogen: null, version: 1, updatedAt: null, kunde: { name: 'K' }, now: NOW })
for (const a of ['abo', 'projekt']) { const g = gen({ einrichtungAbrechnung: a, unlockedBy: a }); assert.doesNotMatch(g.text, ALT, a); assert.doesNotMatch(g.kurz, ALT, a) }
assert.match(gen({ einrichtungAbrechnung: 'abo', unlockedBy: 'abo' }).text, /Einrichtung aus dem Abo, Zeit im Arbeitsblatt aufs Abo-Stundenkonto anrechnen \(Vorschuss möglich\)\./)
assert.match(gen({ einrichtungAbrechnung: 'projekt', unlockedBy: 'projekt' }).text, /im Projekt enthalten, Zeit im Arbeitsblatt, nicht aufs Abo anrechnen\./)
assert.match(gen({ unlockedByPurchase: true }).kurz, /Nicht auf das Stundenkontingent anrechnen/)
// Ersatztexte der Datenschutz-Regel und Schritt 6
assert.match(gen({ einrichtungAbrechnung: 'abo', unlockedBy: 'abo' }).kurz, /das gehört zur Einrichtung und zählt mit zum Stundenkonto/)
assert.match(gen({ einrichtungAbrechnung: 'projekt', unlockedBy: 'projekt' }).kurz, /das gehört zur Einrichtung und ist im Projekt enthalten/)
assert.match(gen({ einrichtungAbrechnung: 'abo', unlockedBy: 'abo' }).kurz, /6\. Ticket erledigen und die Zeit im Arbeitsblatt buchen\.(\n|$)/)
// Notfalltext (Anleitung nicht erzeugbar, hier: leere $id, ladeStand wirft): im Abo-Modell ohne Kontingent-Satz, ohne Marker wie heute
assert.doesNotMatch(await termineTicketDetails(ctx('termine', { einrichtungAbrechnung: 'abo', unlockedBy: 'abo', $id: '' })), ALT)
assert.match(await termineTicketDetails(ctx('termine', { $id: '' })), /Nicht auf das Stundenkontingent anrechnen\./)
})
+54
View File
@@ -0,0 +1,54 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Kundenart im Zustimmungsnachweis (V4): nur geschrieben, wenn uebergeben; ungueltig ist 400 ohne Schreibzugriff
const schema = { featureConsents: schemaKeys('featureConsents') } // bleibt aenderbar wie in featureSetup.test.js
const aw = await startAppwriteMock({ schema })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const reg = await import('../server/services/featureRegistry.js')
const { speichereZustimmung, speichereNachweisAusAnfrage } = await import('../server/services/featureConsents.js')
const base = { customerId: 'c1', projectId: 'p1', featureKey: 'termine', stripeSessionId: '', fassungen: { zustimmung: 'x' }, texte: [{ id: 'knopf', text: 'Einschalten', haken: false }], ip: '203.0.113.9', userAgent: 'UA/1', konto: 'k', at: '2026-10-17T08:00:00.000Z' }
test('kundenart wird gespeichert; ohne Angabe fehlt der Schlüssel, auch bei altem Schema', async () => {
assert.equal((await speichereZustimmung({ ...base, kundenart: 'privat' })).kundenart, 'privat')
assert.equal((await speichereZustimmung({ ...base, kundenart: 'unternehmen' })).kundenart, 'unternehmen')
schema.featureConsents = schemaKeys('featureConsents').filter((k) => k !== 'kundenart') // Setup-Skript noch nicht gelaufen
assert.equal(Object.hasOwn(await speichereZustimmung(base), 'kundenart'), false)
await assert.rejects(() => speichereZustimmung({ ...base, kundenart: 'privat' }), /Unknown attribute/)
schema.featureConsents = schemaKeys('featureConsents')
})
test('ungültige Kundenart: 400, nichts geschrieben', async () => {
const vorher = aw.docs('featureConsents').size
for (const kundenart of ['firma', '', 'Privat', 3, null]) {
await assert.rejects(() => speichereZustimmung({ ...base, kundenart }), (e) => e.status === 400 && /Kundenart ungültig/.test(e.message), String(kundenart))
}
assert.equal(aw.docs('featureConsents').size, vorher)
})
test('Nachweis aus der Anfrage im Abo-Modell: Zeile, Hinweis, optional Häkchen, Knopf; Fassungen ohne widerruf', async () => {
const req = { ip: '203.0.113.9', get: (h) => (h === 'user-agent' ? 'UA/1' : ''), session: { name: 'Kunde Eins', email: 'k@example.invalid' } }
const feature = reg.featureConfig('termine')
await speichereNachweisAusAnfrage(req, { feature: reg.aboweg(feature), customerId: 'c1', projectId: 'p1', klickAt: '2026-10-17T08:00:00.000Z', kundenart: 'unternehmen' })
await speichereNachweisAusAnfrage(req, { feature: reg.aboweg(feature, { beginn: true }), customerId: 'c1', projectId: 'p2', klickAt: '2026-10-17T08:00:00.000Z', kundenart: 'privat' })
const [a, b] = [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'termine' && d.projectId.startsWith('p')).slice(-2)
assert.deepEqual(JSON.parse(a.texte).map((t) => t.id), ['einschalten-zeile', 'hinweis', 'knopf']) // termine trägt den Hinweis (Pflichten der alten Häkchen)
assert.equal(JSON.parse(a.texte)[1].text, reg.consentAbo('termine').items[1].text)
assert.deepEqual(JSON.parse(b.texte).map((t) => [t.id, t.haken]), [['einschalten-zeile', false], ['hinweis', false], ['beginn', true], ['knopf', false]])
assert.deepEqual(Object.keys(JSON.parse(a.fassungen)).sort(), ['avv', 'leistungsbeschreibung', 'zustimmung'])
assert.deepEqual([a.kundenart, b.kundenart, a.konto, a.stripeSessionId], ['unternehmen', 'privat', 'Kunde Eins <k@example.invalid>', ''])
})
test('Nachweis aus der Anfrage ohne kundenart: Schlüssel fehlt (alter Weg bleibt)', async () => {
const req = { ip: '203.0.113.9', get: () => '', session: { name: 'Kunde Eins', email: 'k@example.invalid' } }
await speichereNachweisAusAnfrage(req, { feature: reg.featureConfig('news'), customerId: 'c1', projectId: 'p9', klickAt: '2026-10-17T08:00:00.000Z' })
const doc = [...aw.docs('featureConsents').values()].find((d) => d.projectId === 'p9')
assert.equal(Object.hasOwn(doc, 'kundenart'), false)
})
+2 -2
View File
@@ -188,8 +188,8 @@ test('Schema featureConsents: drei Indizes mit den Attributen aus der Vorgabe (i
})
test('schemaKeys(featureConsents) liefert dieselben Schluessel, der Nachbau nimmt sie an und lehnt andere ab', async () => {
assert.deepEqual(schemaKeys('featureConsents'), CONSENT_KEYS)
assert.deepEqual(schemaKeys('featureConsents'), FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key))
assert.deepEqual(schemaKeys('featureConsents'), [...CONSENT_KEYS, 'kundenart'])
assert.deepEqual(schemaKeys('featureConsents'), [...FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key), 'kundenart'])
schema.featureConsents = schemaKeys('featureConsents')
const { createDocument } = await import('../server/services/appwriteAdmin.js')
const doc = await createDocument(config.collections.featureConsents, {
+114
View File
@@ -0,0 +1,114 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
// Gesammelte Widerrufsfristen (V6): Abo-Abschluss, Wechsel, Zusatzspeicher (nur Plus), bezahlte Funktionen, Auftraege
const aw = await startAppwriteMock({
schema: {
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
portalFeatures: schemaKeys('portalFeatures'),
featureConsents: schemaKeys('featureConsents'),
auftraege: schemaKeys('auftraege'),
},
datetimes: AUFTRAEGE_DATETIMES,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, rawGet } = await import('./support/testApp.js')
const { customers, customerCookie, kundenansichtCookie, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { sammleFristen } = await import('../server/services/fristen.js')
const { default: fristenRoutes } = await import('../server/routes/fristen.js')
const app = await startTestApp({ '/api/fristen': fristenRoutes })
const modusVorher = config.auftraege.mode
after(() => {
config.auftraege.mode = modusVorher
return Promise.all([app.close(), aw.close(), st.close()])
})
const JETZT = new Date('2026-10-17T08:00:00.000Z')
const ereignis = (id, type, contractAt, over = {}) =>
aw.seed('contractEvents', { $id: id, customerId: 'c1', subscriptionId: 'sub_h', type, productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }), ...over })
const aufbau = () => { // Ende je Quelle: wechsel Mo 26.10., funktion Do 29.10., speicher Fr 30.10., abschluss Mo 02.11.
ereignis('ev_a', 'abschluss', '2026-10-17T07:00:00.000Z')
ereignis('ev_w', 'wechsel', '2026-10-10T09:00:00.000Z')
ereignis('ev_s', 'speicher', '2026-10-16T09:00:00.000Z', { details: JSON.stringify({ contractAt: '2026-10-16T09:00:00.000Z', fromUnits: 0, toUnits: 1 }) })
ereignis('ev_m', 'speicher', '2026-10-16T10:00:00.000Z', { details: JSON.stringify({ contractAt: '2026-10-16T10:00:00.000Z', fromUnits: 2, toUnits: 1 }) }) // Minus: keine Frist
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'setup', enabled: false, unlockedByPurchase: true, orderedAt: '2026-10-15T09:00:00.000Z' })
}
beforeEach(async () => {
for (const collection of ['contractEvents', 'portalFeatures', 'auftraege', 'subscriptions', 'websiteProjects']) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
aw.seed('customers', { ...customers.c1 })
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei' })
config.auftraege.mode = 'off'
})
test('mehrere Quellen, aufsteigend nach Ende; Speicher-Plus zählt, Minus nicht', async () => {
aufbau()
const r = await sammleFristen('c1', { now: JETZT })
assert.deepEqual(r.fristen, [
{ quelle: 'wechsel', bezeichnung: 'Wechsel ins Wartungs-Abo', bis: '2026-10-26T22:59:59.999Z' },
{ quelle: 'funktion', bezeichnung: 'News-Pinnwand', bis: '2026-10-29T22:59:59.999Z', featureKey: 'news', projektName: 'Bäckerei' },
{ quelle: 'speicher', bezeichnung: 'Zusatzspeicher', bis: '2026-10-30T22:59:59.999Z' },
{ quelle: 'abschluss', bezeichnung: 'Abo-Abschluss', bis: '2026-11-02T22:59:59.999Z' },
])
assert.deepEqual([r.fruehestesEnde, r.anzeigen], ['2026-10-26T22:59:59.999Z', true])
})
test('Kundenart: Betrieb hat Fristen, aber anzeigen false; privat, leer und unbekannt zeigen', async () => {
aufbau()
for (const [kundenart, anzeigen] of [['unternehmen', false], ['privat', true], ['', true], ['kaputt', true]]) {
aw.seed('customers', { ...customers.c1, kundenart })
const r = await sammleFristen('c1', { now: JETZT })
assert.deepEqual([r.fristen.length, r.anzeigen], [4, anzeigen], kundenart)
}
})
test('abgelaufene oder fehlende Fristen: leer, fruehestesEnde null, anzeigen false', async () => {
ereignis('ev_alt', 'abschluss', '2026-09-20T09:00:00.000Z') // Ende Mo 05.10.
assert.deepEqual(await sammleFristen('c1', { now: JETZT }), { fristen: [], fruehestesEnde: null, anzeigen: false })
})
test('Aufträge zählen nur mit Schalter und Snapshot-Kundenart privat', async () => {
const ab = (kundenart, status = 'versendet') =>
aw.seed('auftraege', { $id: 'ab1', nummer: 'AB-2026-0007', kundenId: 'c1', status, gesendetAm: '2026-10-16T09:00:00.000Z', kundeJson: JSON.stringify({ kundenart }) })
ab('privat')
config.auftraege.mode = 'off'
assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, [])
config.auftraege.mode = 'on'
assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, [{ quelle: 'projekt', bezeichnung: 'Auftrag AB-2026-0007', bis: '2026-10-30T22:59:59.999Z' }])
ab('privat', 'bestaetigt')
assert.equal((await sammleFristen('c1', { now: JETZT })).fristen.length, 1)
ab('privat', 'widerrufen')
assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, [])
ab('unternehmen')
assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, [])
})
test('Lesefehler werden weitergereicht', async () => {
aufbau()
aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 })
await assert.rejects(() => sammleFristen('c1', { now: JETZT }))
})
test('GET /api/fristen: 401 ohne Anmeldung, Form, no-store, Kundenansicht erlaubt; Mount in server/index.js', async () => {
assert.equal((await rawGet('/api/fristen')).status, 401)
const res = await rawGet('/api/fristen', { cookie: customerCookie('c1') })
assert.deepEqual([res.status, Object.keys(res.body).sort(), res.headers.get('cache-control')], [200, ['anzeigen', 'fristen', 'fruehestesEnde'], 'no-store'])
assert.equal((await rawGet('/api/fristen', { cookie: kundenansichtCookie({ customerId: 'c1' }) })).status, 200)
const src = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
assert.ok(src.indexOf("app.use('/api/fristen', fristenRoutes)") > src.indexOf('app.use(sessionMiddleware())'))
assert.ok(src.indexOf("app.use('/api/fristen', fristenRoutes)") < src.indexOf('app.use(express.static(publicDir))'))
assert.ok(src.indexOf("app.use('/api/fristen', fristenRoutes)") > src.indexOf("app.use('/api/funktionen', funktionenRoutes)"))
})
+218
View File
@@ -0,0 +1,218 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.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'
// Generisches Einschalten im Abo (V7): einschalten() fuer news, termine, shop, unterkunft und, ueber einschaltenZeiten, zeiten.
// Sperre feature-order, Nachweis vor Zustand, Ticket danach, Heilung statt Doppelticket.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
featureConsents: schemaKeys('featureConsents'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
},
datetimes: {
...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
featureConsents: ['acceptedAt'],
},
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
const { ABRECHNUNGS_ZEILEN } = await import('../server/services/ticketAdmin.js')
const { ABRECHNUNGEN, EINSCHALTEN_TEXTE, aboFehlerText, einschalten } = await import('../server/services/funktionEinschalten.js')
after(() => Promise.all([aw.close(), st.close()]))
after(() => setMailTransportForTests(null))
const JETZT = new Date('2026-10-17T08:00:00.000Z') // ein Samstag
const modusZeiten = { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] }
after(() => {
config.zeiten.mode = modusZeiten.mode
config.zeiten.pilotEmails = modusZeiten.pilotEmails
})
let aufrufe
const nachweis = async () => {
aufrufe.push(aw.docs('portalFeatures').get('f_p1_news')?.state ?? 'kein Dokument')
}
const E = (over = {}) => einschalten({ customerId: 'c1', projectId: 'p1', key: 'news', by: 'kunde', abrechnung: 'abo', nachweis, now: JETZT, ...over })
const spuren = () => ({ docs: aw.docs('portalFeatures').size, tickets: aw.docs('workorders').size })
const projekt = (id, customerId = 'c1') => aw.seed('websiteProjects', { $id: id, customerId, projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static' })
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geaendert und geloescht
const loescheAbos = async () => {
for (const id of aw.docs('subscriptions').keys()) await deleteDocument('subscriptions', id)
}
let sink
let rebuilds
const originalRebuild = zeitenDeps.rebuild
beforeEach(async () => {
aufrufe = []
for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects', 'contractEvents']) {
for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
resetZeitenCacheForTests()
seedFixtures(aw)
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
for (const id of ['p1', 'p2', 'p3']) projekt(id)
projekt('p_fremd', 'c2')
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true })
config.zeiten.mode = 'pilot'
config.zeiten.pilotEmails = ['*']
sink = mailSink()
setMailTransportForTests(sink)
rebuilds = []
zeitenDeps.rebuild = async (p) => {
rebuilds.push(p.$id)
return { queued: true }
}
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
mock.method(console, 'log', () => {})
})
afterEach(() => {
mock.restoreAll()
zeitenDeps.rebuild = originalRebuild
})
test('Erfolg im Abo: setup, unlockedBy abo, Marker, genau ein Ticket, Nachweis vor dem Zustand', async () => {
const { doc, ticketWoid } = await E()
assert.deepEqual([doc.state, doc.enabled, doc.unlockedBy, doc.unlockedByPurchase, doc.einrichtungAbrechnung], ['setup', false, 'abo', false, 'abo'])
assert.equal(aufrufe.length, 1)
assert.ok(['kein Dokument', 'off'].includes(aufrufe[0]), 'Nachweis lief vor dem Zustand')
const [t] = [...aw.docs('workorders').values()]
assert.deepEqual([aw.docs('workorders').size, t.topic, t.einrichtungFuer, t.einrichtungAbrechnung, t.woid], [1, 'News-Pinnwand einbauen – Projekt p1', 'news', 'abo', ticketWoid])
assert.ok(t.details.endsWith(ABRECHNUNGS_ZEILEN.abo))
assert.equal(doc.ticketId, t.$id)
assert.deepEqual(ABRECHNUNGEN, ['abo', 'projekt'])
})
test('Team im Projekt: ohne Abo-Prüfung, unlockedBy projekt, Notiz und Ticketzeile', async () => {
await loescheAbos()
const { doc } = await E({ by: 'team', abrechnung: 'projekt', staff: { name: 'Andrej' }, nachweis: null })
assert.deepEqual([doc.unlockedBy, doc.einrichtungAbrechnung], ['projekt', 'projekt'])
assert.match(doc.adminNote, /Andrej: Eingeschaltet \(projekt\)$/)
assert.ok([...aw.docs('workorders').values()][0].details.endsWith(ABRECHNUNGS_ZEILEN.projekt))
assert.equal(aufrufe.length, 0, 'ohne nachweis wird keiner geschrieben')
})
test('Team: staff.adminNote ersetzt die Standardnotiz', async () => {
const { doc } = await E({ by: 'team', abrechnung: 'abo', staff: { name: 'Andrej', adminNote: 'Per Telefon vereinbart' }, nachweis: null })
assert.equal(doc.adminNote, 'Per Telefon vereinbart')
})
test('Konflikte mit klaren Codes, ohne zweites Ticket oder zweiten Nachweis', async () => {
await E()
const s = spuren()
const n = aufrufe.length
await assert.rejects(E(), (e) => e.status === 409 && e.code === 'bereits_eingeschaltet' && e.body.code === 'bereits_eingeschaltet')
await updateDocument('portalFeatures', 'f_p1_news', { state: 'ordered' })
await assert.rejects(E(), (e) => e.status === 409 && e.code === 'bestellung_laeuft')
await updateDocument('portalFeatures', 'f_p1_news', { state: 'disabled' })
await assert.rejects(E(), (e) => e.status === 409 && e.code === 'abgeschaltet')
await updateDocument('portalFeatures', 'f_p1_news', { state: 'active' })
await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet')
assert.deepEqual([spuren(), aufrufe.length], [s, n])
assert.equal(EINSCHALTEN_TEXTE.bereits, 'Diese Funktion ist für dieses Projekt schon eingeschaltet.')
})
test('Heilung: setup ohne Ticket legt nur das Ticket an; gekauft (kauf) wird nicht geheilt', async () => {
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false, unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo' })
const { doc } = await E()
assert.deepEqual([aufrufe.length, aw.docs('workorders').size, doc.state, Boolean(doc.ticketId)], [0, 1, 'setup', true])
await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet')
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'setup', unlockedBy: 'kauf', unlockedByPurchase: true })
await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet')
})
test('Heilung nimmt die gespeicherte Abrechnung des Dokuments', async () => {
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false, unlockedBy: 'projekt', unlockedByPurchase: false, einrichtungAbrechnung: 'projekt' })
const { doc } = await E({ abrechnung: 'abo' })
assert.equal(aw.docs('workorders').get(doc.ticketId).einrichtungAbrechnung, 'projekt')
})
test('Ticket scheitert: 503, setup bleibt, der nächste Aufruf heilt ohne zweites Ticket', async () => {
aw.failNext({ collection: 'workorders', method: 'POST', status: 500 })
await assert.rejects(E(), (e) => e.status === 503 && e.message === EINSCHALTEN_TEXTE.ticket)
assert.deepEqual([aw.docs('portalFeatures').get('f_p1_news').state, aw.docs('workorders').size], ['setup', 0])
await E()
assert.deepEqual([aw.docs('workorders').size, aufrufe.length], [1, 1])
})
test('gleichzeitig: ein Erfolg, ein 409, ein Ticket, ein Nachweis', async () => {
const r = await Promise.allSettled([E(), E()])
assert.deepEqual(r.map((x) => x.status).sort(), ['fulfilled', 'rejected'])
assert.equal(r.find((x) => x.status === 'rejected').reason.code, 'bereits_eingeschaltet')
assert.deepEqual([aw.docs('workorders').size, aufrufe.length], [1, 1])
})
test('Nachweis scheitert: kein Zustand, kein Ticket', async () => {
await assert.rejects(E({ nachweis: async () => { throw Object.assign(new Error('Nachweis weg'), { status: 500 }) } }), /Nachweis weg/)
assert.deepEqual(spuren(), { docs: 0, tickets: 0 })
})
test('Abo fehlt unter der Sperre: abo_fehlt mit Grund und wechsel, nichts geschrieben', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_h', hostingDoc)) // Hosting läuft, news ist Wartung
await assert.rejects(E(), (e) => e.status === 409 && e.code === 'abo_fehlt' && e.body.grund === 'falsches_abo' && e.body.wechsel === true && e.message === EINSCHALTEN_TEXTE.abo.falsches_abo)
await loescheAbos()
await assert.rejects(E(), (e) => e.body.grund === 'kein_abo' && e.body.wechsel === false)
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-11-03T05:00:00.000Z' })) // Probezeit: Abo beginnt später
await assert.rejects(E(), (e) => e.status === 409 && e.code === 'abo_fehlt' && e.body.grund === 'beginnt_spaeter' && e.body.wechsel === false && e.body.ab === '2026-11-03T05:00:00.000Z' && e.message === 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.')
assert.equal(aboFehlerText('beginnt_spaeter', '2026-11-03T05:00:00.000Z'), 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.')
assert.equal(aboFehlerText('kein_abo'), EINSCHALTEN_TEXTE.abo.kein_abo)
assert.throws(() => aboFehlerText('gibtEsNicht'), TypeError)
assert.deepEqual([spuren(), aufrufe.length], [{ docs: 0, tickets: 0 }, 0])
for (const text of Object.values(EINSCHALTEN_TEXTE.abo)) assert.ok(!/€/.test(text))
})
test('Abo nicht lesbar: 503, nichts geschrieben (fail-closed)', async () => {
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
await assert.rejects(E(), (e) => e.status === 503 && e.message === EINSCHALTEN_TEXTE.nichtPruefbar)
assert.deepEqual([spuren(), aufrufe.length], [{ docs: 0, tickets: 0 }, 0])
})
test('Öffnungszeiten über den bestehenden Weg: Marker, unlockedBy je Abrechnung, kein Preistext im Abo-Fehler', async () => {
const { doc } = await E({ key: 'zeiten', nachweis: async () => {} })
assert.deepEqual([doc.unlockedBy, doc.einrichtungAbrechnung, doc.state], ['abo', 'abo', 'setup'])
assert.equal(aw.docs('workorders').get(doc.ticketId).einrichtungAbrechnung, 'abo')
await assert.rejects(E({ key: 'zeiten' }), (e) => e.status === 409 && e.code === 'bereits_eingeschaltet')
await loescheAbos()
await assert.rejects(E({ key: 'zeiten', projectId: 'p2' }), (e) => e.code === 'abo_fehlt' && !/€/.test(e.message))
const t = await E({ key: 'zeiten', projectId: 'p3', by: 'team', abrechnung: 'projekt', staff: { name: 'Andrej' } })
assert.equal(t.doc.unlockedBy, 'projekt')
assert.equal(aw.docs('workorders').get(t.doc.ticketId).einrichtungAbrechnung, 'projekt')
})
test('Öffnungszeiten: Heilung und Zustände mit Codes', async () => {
aw.seed('portalFeatures', { $id: 'f_p1_zeiten', customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', siteKey: 'abcdefghijklmnop', state: 'ordered', enabled: false })
await assert.rejects(E({ key: 'zeiten' }), (e) => e.code === 'bestellung_laeuft')
await updateDocument('portalFeatures', 'f_p1_zeiten', { state: 'disabled' })
await assert.rejects(E({ key: 'zeiten' }), (e) => e.code === 'abgeschaltet')
await updateDocument('portalFeatures', 'f_p1_zeiten', { state: 'setup', unlockedBy: 'abo', unlockedByPurchase: false, einbau: '', einrichtungAbrechnung: 'abo' })
const heil = await E({ key: 'zeiten' })
assert.deepEqual([heil.doc.state, Boolean(heil.doc.ticketId), aufrufe.length], ['setup', true, 0])
})
test('Parameter und Projekt', async () => {
for (const bad of [{ key: 'analyse' }, { key: 'foo' }, { key: '__proto__' }, { by: 'x' }, { abrechnung: 'frei' }, { abrechnung: '' }, { abrechnung: undefined }]) {
await assert.rejects(E(bad), TypeError, JSON.stringify(bad))
}
await assert.rejects(E({ projectId: 'p_fremd' }), (e) => e.status === 403)
aw.seed('websiteProjects', { $id: 'p_archiv', customerId: 'c1', projectName: 'Alt', archived: true })
await assert.rejects(E({ projectId: 'p_archiv' }), (e) => e.status === 409)
assert.deepEqual(spuren(), { docs: 0, tickets: 0 })
})
+116
View File
@@ -0,0 +1,116 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.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'
// Meldung beim Einschalten im Abo-Modell (P6 Task 6): K38 an den Kunden, T24 ans Team (aboFunktionenMeldung.js, Aufruf am Ende
// von einschalten). Aufbau wie test/funktionEinschalten.test.js, dazu hourCredits und die Mail-Senke.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
featureConsents: schemaKeys('featureConsents'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
hourCredits: schemaKeys('hourCredits'),
},
datetimes: {
...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
featureConsents: ['acceptedAt'],
},
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { customers, hostingDoc, 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 { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
const { einschalten } = await import('../server/services/funktionEinschalten.js')
const { meldeEingeschaltet, meldungDeps } = await import('../server/services/aboFunktionenMeldung.js')
after(() => Promise.all([aw.close(), st.close()]))
after(() => setMailTransportForTests(null))
const JETZT = new Date('2026-10-17T08:00:00.000Z') // ein Samstag
const modusZeiten = { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] }
after(() => {
config.zeiten.mode = modusZeiten.mode
config.zeiten.pilotEmails = modusZeiten.pilotEmails
})
const E = (over = {}) => einschalten({ customerId: 'c1', projectId: 'p1', key: 'news', by: 'kunde', abrechnung: 'abo', now: JETZT, ...over })
const projekt = (id, customerId = 'c1') => aw.seed('websiteProjects', { $id: id, customerId, projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static' })
const kunde = (over = {}) => ({ doc: aw.docs('portalFeatures').get('f_p1_news'), ticketWoid: '100150', by: 'kunde', abrechnung: 'abo', ...over })
let mails
const betreff = (start) => mails.sent.filter((m) => m.subject.startsWith(start))
const originalRebuild = zeitenDeps.rebuild
beforeEach(async () => {
for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects', 'contractEvents', 'hourCredits']) {
for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
resetZeitenCacheForTests()
seedFixtures(aw)
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
for (const id of ['p1', 'p2']) projekt(id)
aw.seed('customers', { ...customers.c1 })
config.zeiten.mode = 'pilot'
config.zeiten.pilotEmails = ['*']
mails = mailSink()
setMailTransportForTests(mails)
zeitenDeps.rebuild = async () => ({ queued: true })
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
mock.method(console, 'log', () => {})
})
afterEach(() => {
mock.restoreAll()
zeitenDeps.rebuild = originalRebuild
})
test('Kunde, Wartung ohne Guthaben: K38 mit Konto-Satz an den Kunden, T24 mit Vorschuss ans Team', async () => {
await E() // einschalten wie P5 Task 9: ruft die Meldung selbst
const [k38] = betreff('Deine Funktion wird eingerichtet: News-Pinnwand'); assert.deepEqual(k38.to.map((a) => a.address), ['k@example.invalid'])
assert.match(k38.text, /geht von deinem Stundenkonto ab/); assert.match(k38.text, /\(Ticket #\w+\)/)
const [t24] = betreff('[Abo] Funktion eingeschaltet: News-Pinnwand'); assert.match(t24.text, /Guthaben 0 min, Vorschuss 0 min/)
assert.equal(betreff('[Abo] Funktion eingeschaltet').length, 1)
})
test('Guthaben reicht (Gutschrift 300 min): T24 ohne Vorschuss-Satz; Hosting-Abo: auch keiner', async () => {
aw.seed('hourCredits', { $id: 'h1', customerId: 'c1', minutes: 300, grantedAt: '2026-10-01T00:00:00.000Z', expiresAt: '2099-01-01T00:00:00.000Z' })
await E(); assert.doesNotMatch(betreff('[Abo] Funktion eingeschaltet')[0].text, /reicht voraussichtlich nicht/)
// ein Hosting-Abo hat kein Stundenkonto: auch ohne Guthaben kein Vorschuss-Satz
await deleteDocument('hourCredits', 'h1'); await deleteDocument('subscriptions', 'sub_h'); aw.seed('subscriptions', subscriptionDoc('sub_h', hostingDoc))
mails.sent.length = 0
assert.deepEqual(await meldeEingeschaltet(kunde()), { k38: true, t24: true })
assert.doesNotMatch(betreff('[Abo] Funktion eingeschaltet')[0].text, /reicht voraussichtlich nicht/)
})
test('Terminplaner: K38 mit zwei PDF-Anhängen; PDFs nicht lesbar: K38 ohne Anhang, T24 mit Hinweis', async () => {
await E({ key: 'termine' }); const k = betreff('Deine Funktion wird eingerichtet')[0]
assert.deepEqual(k.attachments.map((a) => a.contentType), ['application/pdf', 'application/pdf']); assert.match(k.text, /hängen als PDF an/)
mails.sent.length = 0; const echt = meldungDeps.anhaenge; meldungDeps.anhaenge = async () => { throw new Error('weg') }
try { await E({ key: 'termine', projectId: 'p2' }) } finally { meldungDeps.anhaenge = echt }
assert.equal(betreff('Deine Funktion wird eingerichtet')[0].attachments.length, 0); assert.match(betreff('[Abo]')[0].text, /von Hand nachsenden/)
})
test('Team im Projekt: K38 mit Projektkosten-Satz, kein Konto-Satz, kein T24; Zeiten: nur T24 (K27 bleibt)', async () => {
await E({ by: 'team', abrechnung: 'projekt', staff: { name: 'Andrej' }, nachweis: null })
const k = betreff('Deine Funktion wird eingerichtet')[0]; assert.match(k.text, /Von deinem Stundenkonto geht nichts ab/); assert.match(k.text, /Die Funktion läuft, solange dein Wartungs-Abo läuft/); assert.doesNotMatch(k.text, /geht von deinem Stundenkonto ab/); assert.equal(betreff('[Abo]').length, 0)
mails.sent.length = 0; await E({ key: 'zeiten', projectId: 'p2' })
assert.deepEqual([betreff('Deine Funktion wird eingerichtet').length, betreff('[Abo] Funktion eingeschaltet').length], [0, 1])
})
test('Wiederholung und Heilung: höchstens eine Meldung je Einschalten; Versandfehler und Kunde ohne Adresse brechen nichts ab', async () => {
await E(); await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet'); assert.equal(betreff('Deine Funktion').length, 1)
mails.sent.length = 0; aw.failNext({ collection: 'workorders', method: 'POST', status: 500 })
await assert.rejects(E({ projectId: 'p2' }), (e) => e.status === 503); assert.equal(mails.sent.length, 0) // Ticket scheitert: noch keine Mail
await E({ projectId: 'p2' }); assert.equal(betreff('Deine Funktion').length, 1) // Heilung: genau eine
setMailTransportForTests({ sendMail: async () => { throw new Error('smtp') } })
const r = await meldeEingeschaltet(kunde()); assert.deepEqual(r, { k38: false, t24: false })
setMailTransportForTests(mails); aw.seed('customers', { ...customers.c1, email: '' }); assert.equal((await meldeEingeschaltet(kunde())).k38, false)
})
+117
View File
@@ -0,0 +1,117 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Team-Aktivierung im Abo-Modell (P6 Task 7, V10): POST /api/team/funktionen/:customerId/:key mit abrechnung. Pilotkunde c1 (Wartung),
// c2 außerhalb der neuen Ansicht. Aufbauphase (E9, N28): projekt ohne jedes bisherige Abo ergibt 200 mit warnung abo_fehlt.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
featureConsents: schemaKeys('featureConsents'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post } = await import('./support/testApp.js')
const { hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { ABRECHNUNGS_ZEILEN } = await import('../server/services/ticketAdmin.js')
const app = await startTestApp({ '/api/team': teamRoutes })
const mails = mailSink()
setMailTransportForTests(mails)
const MODUS_SHOP = { mode: config.shop.mode, pilotEmails: [...config.shop.pilotEmails] }
const MODUS_NEU = { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] }
after(() => {
config.shop.mode = MODUS_SHOP.mode
config.shop.pilotEmails = MODUS_SHOP.pilotEmails
config.kundenansichtNeu.mode = MODUS_NEU.mode
config.kundenansichtNeu.pilotEmails = MODUS_NEU.pilotEmails
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
const auth = { headers: { authorization: 'Bearer jwt-staff' } }
const go = (cid, key, body = {}, pid = 'p1') => post(`/api/team/funktionen/${cid}/${key}`, { projectId: pid, action: 'activate', ...body }, auth)
const feat = (key, pid = 'p1') => aw.docs('portalFeatures').get(`f_${pid}_${key}`)
const nichts = () => assert.deepEqual([aw.docs('portalFeatures').size, aw.docs('workorders').size, aw.docs('featureConsents').size, mails.sent.length], [0, 0, 0, 0])
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht
const leere = async (...collections) => {
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
beforeEach(async () => {
await leere('portalFeatures', 'workorders', 'featureConsents', 'subscriptions', 'websiteProjects')
mails.sent.length = 0
seedFixtures(aw)
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
for (const [id, customerId] of [['p1', 'c1'], ['p2', 'c1'], ['p3', 'c2']]) {
aw.seed('websiteProjects', { $id: id, customerId, projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static' })
}
config.kundenansichtNeu.mode = 'pilot'
config.kundenansichtNeu.pilotEmails = ['k@example.invalid']
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
mock.method(console, 'log', () => {})
})
afterEach(() => mock.restoreAll())
test('Pilotkunde mit Wartung: setup, unlockedBy abo, Ticket mit Abo-Zeile, K38, kein T24; im Projekt: projekt', async () => {
const r = await go('c1', 'news'); assert.equal(r.status, 200, JSON.stringify(r.body)); assert.equal(r.body.warnung, undefined) // Abo deckt: keine Warnung
const d = feat('news'); assert.deepEqual([d.state, d.unlockedBy, d.einrichtungAbrechnung, d.unlockedByPurchase], ['setup', 'abo', 'abo', false])
const t = aw.docs('workorders').get(d.ticketId); assert.deepEqual([t.einrichtungAbrechnung, t.details.endsWith(ABRECHNUNGS_ZEILEN.abo)], ['abo', true])
assert.deepEqual([mails.sent.filter((m) => /^Deine Funktion wird eingerichtet/.test(m.subject)).length, mails.sent.filter((m) => m.subject.startsWith('[Abo]')).length], [1, 0])
assert.equal((await go('c1', 'shop', { abrechnung: 'projekt' })).status, 200); const s = feat('shop'); assert.deepEqual([s.unlockedBy, s.einrichtungAbrechnung], ['projekt', 'projekt'])
assert.ok(aw.docs('workorders').get(s.ticketId).details.endsWith(ABRECHNUNGS_ZEILEN.projekt))
})
test('kein deckendes Abo: 409 abo_fehlt mit Grund, nichts geschrieben (abo immer; projekt, sobald der Kunde ein Abo hat oder hatte); ungültige Abrechnung 400', async () => {
for (const [abo, grund, wechsel] of [[hostingDoc, 'falsches_abo', true], [{ ...wartungDoc, cancelAtPeriodEnd: true }, 'gekuendigt', false], [{ ...wartungDoc, status: 'canceled' }, 'kein_abo', false]]) {
await leere('subscriptions'); aw.seed('subscriptions', subscriptionDoc('sub_h', abo))
for (const abrechnung of ['abo', 'projekt']) { const r = await go('c1', 'termine', { abrechnung }); assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel, typeof r.body.error], [409, 'abo_fehlt', grund, wechsel, 'string'], `${grund} ${abrechnung}`) }
nichts()
}
await leere('subscriptions') // noch nie ein Abo: aus dem Abo ist das Stundenkonto nötig, also hart
const ohne = await go('c1', 'termine'); assert.deepEqual([ohne.status, ohne.body.code, ohne.body.grund], [409, 'abo_fehlt', 'kein_abo']); nichts()
assert.equal((await go('c1', 'news', { abrechnung: 'frei' })).status, 400); nichts()
})
test('Projektstart ohne jedes Abo (E9, Aufbauphase): 200 mit warnung abo_fehlt, Funktion in setup mit Ticket; abgebrochene Bestellung zählt nicht; mit deckendem Abo keine Warnung', async () => {
await leere('subscriptions'); aw.seed('subscriptions', subscriptionDoc('sub_i', { ...wartungDoc, status: 'incomplete' }))
const r = await go('c1', 'shop', { abrechnung: 'projekt' }); assert.equal(r.status, 200, JSON.stringify(r.body)); assert.deepEqual([r.body.warnung, r.body.grund], ['abo_fehlt', 'kein_abo'])
const d = feat('shop'); assert.deepEqual([d.state, d.unlockedBy, d.einrichtungAbrechnung], ['setup', 'projekt', 'projekt']); assert.ok(aw.docs('workorders').get(d.ticketId))
assert.equal(mails.sent.filter((m) => /^Deine Funktion wird eingerichtet/.test(m.subject)).length, 1) // K38 mit dem Satz zur Bedingung (Task 3)
await leere('subscriptions'); const z = await go('c1', 'news', { abrechnung: 'projekt' }, 'p2'); assert.deepEqual([z.status, z.body.warnung], [200, 'abo_fehlt'])
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)); const ok = await go('c1', 'news', { abrechnung: 'projekt' }); assert.deepEqual([ok.status, ok.body.warnung, ok.body.grund], [200, undefined, undefined])
})
test('Kunde außerhalb der neuen Ansicht: wie heute direkt aktiv, abrechnung im Body wird ignoriert', async () => {
const r = await go('c2', 'news', { abrechnung: 'projekt' }, 'p3'); assert.equal(r.status, 200)
const d = feat('news', 'p3'); assert.deepEqual([d.state, d.enabled, d.ticketId, d.unlockedBy, d.einrichtungAbrechnung], ['active', true, undefined, undefined, undefined]); assert.equal(aw.docs('workorders').size, 0)
config.kundenansichtNeu.mode = 'off'; await leere('portalFeatures'); assert.equal(feat('news'), undefined)
assert.equal((await go('c1', 'news')).status, 200); assert.equal(feat('news').state, 'active'); config.kundenansichtNeu.mode = 'pilot'
})
test('Bestand und vom Team abgeschaltet: kauf wie heute, disabled/team direkt aktiv (mit Abo), sonst 409', async () => {
aw.seed('portalFeatures', { $id: 'f_p2_termine', customerId: 'c1', projectId: 'p2', featureKey: 'termine', state: 'disabled', disabledReason: 'abo_ende', unlockedBy: 'kauf', unlockedByPurchase: true })
await leere('subscriptions'); const alt = await go('c1', 'termine', {}, 'p2'); assert.deepEqual([alt.status, alt.body.code, /Wartungs-Abo des Kunden läuft nicht/.test(alt.body.error)], [409, undefined, true])
aw.seed('portalFeatures', { $id: 'f_p1_shop', customerId: 'c1', projectId: 'p1', featureKey: 'shop', state: 'disabled', disabledReason: 'team', unlockedBy: 'abo', einrichtungAbrechnung: 'abo', activatedAt: '2026-10-01T09:00:00.000Z' })
assert.equal((await go('c1', 'shop')).body.code, 'abo_fehlt'); aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
assert.equal((await go('c1', 'shop')).status, 200); assert.deepEqual([feat('shop').state, aw.docs('workorders').size], ['active', 0])
})
test('Textform: Nachweis vor dem Zustand, K38 mit PDF-Anhängen, kein K16; Aktion ticket legt das Ticket ohne Stripe nach', async () => {
config.shop.mode = 'on'; config.shop.pilotEmails = []
const text = { textformDate: '2026-10-10', textformChannel: 'whatsapp' }
aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 }) // Nachweis scheitert: Funktion bleibt aus, kein Ticket, keine Mail
assert.ok((await go('c1', 'shop', text)).status >= 500); assert.deepEqual([feat('shop')?.state ?? 'off', aw.docs('workorders').size, mails.sent.length], ['off', 0, 0])
const r = await go('c1', 'shop', text); assert.equal(r.status, 200, JSON.stringify(r.body))
const n = [...aw.docs('featureConsents').values()].find((c) => c.featureKey === 'shop'); assert.match(n.konto, /^Mitarbeiter: /)
assert.ok(mails.sent.find((m) => /^Deine Funktion wird eingerichtet/.test(m.subject)).attachments.length >= 2); assert.equal(mails.sent.filter((m) => /ist bereit/.test(m.subject)).length, 0)
await deleteDocument('workorders', feat('shop').ticketId); await updateDocument('portalFeatures', 'f_p1_shop', { ticketId: '' }) // Ticket weg: nachlegen heilt über einschalten
const calls = st.calls.length; const t = await go('c1', 'shop', { action: 'ticket' }); assert.equal(t.status, 200, JSON.stringify(t.body)); assert.ok(feat('shop').ticketId); assert.equal(st.calls.length, calls)
})
+300
View File
@@ -0,0 +1,300 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.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'
// POST /api/funktionen/:key/einschalten (V5): nur in der neuen Ansicht, Pruefreihenfolge, Nachweis mit Kundenart, Weiche an /zeiten/einschalten
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
featureConsents: schemaKeys('featureConsents'),
shopConfig: schemaKeys('shopConfig'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
},
datetimes: {
...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
featureConsents: ['acceptedAt'],
},
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, sessionCookie } = await import('./support/testApp.js')
const { customerCookie, customers, hostingDoc, kundenansichtCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
const { einschaltenDeps } = await import('../server/services/zeiten/einschalten.js')
const { consentAbo } = await import('../server/services/featureRegistry.js')
const { EINSCHALTEN_TEXTE } = await import('../server/services/funktionEinschalten.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
after(() => setMailTransportForTests(null))
const JETZT = new Date('2026-10-17T08:00:00.000Z') // ein Samstag
const c1 = customerCookie('c1')
const vorher = {
neu: { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] },
termine: config.termine.mode,
shop: config.shop.mode,
zeiten: { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] },
unterlagen: Object.fromEntries(['news', 'termine', 'shop', 'unterkunft', 'zeiten'].map((k) => [k, config.features[k].unterlagenBereit])),
}
after(() => {
Object.assign(config.kundenansichtNeu, vorher.neu)
config.termine.mode = vorher.termine
config.shop.mode = vorher.shop
Object.assign(config.zeiten, vorher.zeiten)
for (const [k, fn] of Object.entries(vorher.unterlagen)) {
if (fn === undefined) delete config.features[k].unterlagenBereit
else config.features[k].unterlagenBereit = fn
}
})
const Z = (key, haken = []) => ({ version: consentAbo(key)?.version ?? 'unbekannt', haken }) // consentAbo aus featureRegistry.js (null für analyse und unbekannte Schlüssel)
const go = (key, body = {}, cookie = c1) => post(`/api/funktionen/${key}/einschalten`, { projectId: 'p1', zustimmung: Z(key), ...body }, { cookie })
const abschluss = (contractAt = JETZT.toISOString()) =>
aw.seed('contractEvents', { $id: 'ev_a', customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }) })
const nichts = () => assert.deepEqual([aw.docs('portalFeatures').size, aw.docs('workorders').size, aw.docs('featureConsents').size], [0, 0, 0])
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht und geaendert
const leere = async (...collections) => {
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
const projekt = (id, over = {}) => aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over })
let sink
const originalRebuild = zeitenDeps.rebuild
beforeEach(async () => {
await leere('subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects', 'contractEvents', 'shopConfig')
resetZeitenCacheForTests()
seedFixtures(aw)
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
for (const id of ['p1', 'p1b', 'p1c']) projekt(id)
projekt('p_archiv', { archived: true })
projekt('p_fremd', { customerId: 'c2' })
config.kundenansichtNeu.mode = 'pilot'
config.kundenansichtNeu.pilotEmails = ['k@example.invalid', 'test@example.invalid']
config.termine.mode = config.shop.mode = 'on'
config.zeiten.mode = 'pilot'
config.zeiten.pilotEmails = ['*']
for (const k of ['news', 'termine', 'shop', 'unterkunft', 'zeiten']) config.features[k].unterlagenBereit = async () => true
einschaltenDeps.jetzt = () => JETZT
sink = mailSink()
setMailTransportForTests(sink)
zeitenDeps.rebuild = async () => ({ queued: true })
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
mock.method(console, 'log', () => {})
})
afterEach(() => {
mock.restoreAll()
zeitenDeps.rebuild = originalRebuild
einschaltenDeps.jetzt = () => new Date()
})
test('Erfolg (Betrieb, Wartung): Form, Zustand, Ticket, Nachweis mit Kundenart, K38 und T24 (P6) ohne Preis, kein Stripe', async () => {
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
const r = await go('news')
assert.deepEqual([r.status, Object.keys(r.body).sort(), r.body.feature.state, r.body.feature.unlockedBy], [200, ['feature', 'ticketWoid'], 'setup', 'abo'])
assert.equal(r.body.ticketWoid, r.body.feature.ticketWoid)
assert.equal(aw.docs('workorders').size, 1)
// P6: das Einschalten meldet K38 an den Kunden und T24 ans Team (aboFunktionenMeldung.js); kein Preis, kein Stripe
assert.deepEqual([sink.sent.map((m) => m.subject.split(':')[0]), st.calls.length], [['Deine Funktion wird eingerichtet', '[Abo] Funktion eingeschaltet'], 0])
assert.doesNotMatch(JSON.stringify(sink.sent), /€/)
const n = [...aw.docs('featureConsents').values()][0]
assert.deepEqual([JSON.parse(n.texte).map((t) => t.id), n.kundenart, n.stripeSessionId], [['einschalten-zeile', 'knopf'], 'unternehmen', ''])
assert.doesNotMatch(JSON.stringify(r.body), /€/)
assert.equal(n.acceptedAt.slice(0, 19), '2026-10-17T08:00:00', 'Zeit des Klicks aus der Uhr der Route')
})
test('Einschalten nur in neuer Ansicht (Pilot-Ränder)', async () => {
const faelle = [['Kunde nicht in der Liste', 'pilot', ['k2@example.invalid']], ['off schlägt *', 'off', ['*']], ['Pilot mit leerer Liste', 'pilot', []]]
for (const [name, mode, liste] of faelle) {
config.kundenansichtNeu.mode = mode
config.kundenansichtNeu.pilotEmails = liste
assert.equal((await go('news')).status, 404, name)
nichts()
}
config.kundenansichtNeu.mode = 'pilot'
config.kundenansichtNeu.pilotEmails = ['*']
assert.equal((await go('news', { projectId: 'p1b' })).status, 200) // * lässt jeden Kunden durch
config.kundenansichtNeu.pilotEmails = ['test@example.invalid'] // Session-E-Mail mit Großbuchstaben und Leerzeichen trifft die Liste (nicht 404)
const schreibweise = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', name: 'Kunde Eins', email: ' TEST@Example.INVALID ' })
assert.notEqual((await go('news', { projectId: 'p1c' }, schreibweise)).status, 404)
assert.equal((await go('news', {}, kundenansichtCookie({ customerId: 'c1' }))).status, 403) // Mitarbeiter-Kundenansicht im Pilot: gesperrt, nicht 404
assert.equal((await post('/api/funktionen/news/einschalten', {})).status, 401)
for (const key of ['analyse', 'foo', '__proto__']) assert.equal((await go(key)).status, 404, key)
})
test('Weiche an /zeiten/einschalten: alte Ansicht alter Weg, neue Ansicht neuer Weg', async () => {
config.kundenansichtNeu.mode = 'off'
const alt = await post('/api/funktionen/zeiten/einschalten', { projectId: 'p1', zustimmung: { version: config.features.zeiten.consentFrei.version, haken: config.features.zeiten.consentFrei.items.filter((i) => i.haken).map((i) => i.id) } }, { cookie: c1 })
assert.deepEqual([alt.status, Object.keys(alt.body).sort()], [200, ['einbau', 'feature']])
await leere('portalFeatures')
config.kundenansichtNeu.mode = 'pilot'
const neu = await go('zeiten')
assert.deepEqual([neu.status, Object.keys(neu.body).sort()], [200, ['feature', 'ticketWoid']])
})
test('Kundenart (leer, unbekannt, gefälscht) und Frist: beginn_noetig mit ab, Häkchen, Betrieb ohne Häkchen', async () => {
abschluss() // Samstag 17.10. + 14 Tage -> Frist bis Mo 02.11., ab = Di 03.11. 00:00 Berlin
for (const kundenart of [undefined, '', 'kaputt', 'Unternehmen']) {
aw.seed('customers', { ...customers.c1, ...(kundenart === undefined ? {} : { kundenart }) })
const r = await go('news', { kundenart: 'unternehmen', unternehmer: true })
assert.deepEqual([r.status, r.body.code, r.body.ab], [409, 'beginn_noetig', '2026-11-02T23:00:00.000Z'], String(kundenart))
assert.match(r.body.error, /03\.11\.2026/)
nichts()
}
assert.equal((await go('news', { zustimmung: Z('news', ['beginn']) })).status, 200)
const p = JSON.parse([...aw.docs('featureConsents').values()][0].texte)
assert.deepEqual(p.map((t) => [t.id, t.haken]), [['einschalten-zeile', false], ['beginn', true], ['knopf', false]])
await leere('portalFeatures', 'workorders', 'featureConsents')
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
assert.equal((await go('news', { zustimmung: Z('news', ['beginn']) })).status, 200) // Betrieb: beginn wird ignoriert
assert.deepEqual(JSON.parse([...aw.docs('featureConsents').values()][0].texte).map((t) => t.id), ['einschalten-zeile', 'knopf'])
await leere('portalFeatures')
aw.seed('customers', { ...customers.c1 })
abschluss('2026-09-01T09:00:00.000Z') // privat, Frist vorbei: kein Häkchen nötig
assert.equal((await go('news')).status, 200)
})
test('Prüfreihenfolge der 409-Codes', async () => {
config.termine.mode = 'off'
await leere('subscriptions')
assert.equal((await go('termine')).body.code, 'nicht_verfuegbar')
config.termine.mode = 'on'
assert.equal((await go('termine')).body.code, 'abo_fehlt') // vor nur_betrieb
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
assert.equal((await go('shop')).body.code, 'nur_betrieb') // privat, vor bestellung_offen
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
const o = await go('shop')
assert.deepEqual([o.body.code, o.body.offen], ['bestellung_offen', ['Deine Webshop-Anfrage ist noch nicht freigegeben.']])
assert.equal((await go('news')).status, 200)
assert.equal((await go('news', { zustimmung: { version: 'alt', haken: [] } })).body.code, 'bereits_eingeschaltet') // vor fassung_veraltet
const alt = await go('zeiten', { zustimmung: { version: 'alt', haken: [] } })
assert.deepEqual([alt.status, alt.body.code, alt.body.consent.version], [409, 'fassung_veraltet', config.aboFunktionen.fassung])
assert.equal(alt.body.error, 'Die Erklärung wurde inzwischen geändert. Bitte lies sie noch einmal und schalte dann ein.')
})
test('Hinweis (Pflichten der alten Häkchen): der Satz der Funktion steht im Nachweis; news hat keinen', async () => {
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
const alt = ['termine', 'shop'].map((k) => config.features[k].bestellpruefung)
for (const k of ['termine', 'shop']) config.features[k].bestellpruefung = async () => [] // Fragebogen und Freigabe sind hier nicht das Thema
try {
for (const key of ['termine', 'shop']) {
const r = await go(key)
assert.equal(r.status, 200, `${key} ${JSON.stringify(r.body)}`)
const t = JSON.parse([...aw.docs('featureConsents').values()].at(-1).texte)
assert.deepEqual(t.map((x) => x.id), ['einschalten-zeile', 'hinweis', 'knopf'], key)
assert.equal(t[1].text, consentAbo(key).items[1].text, key)
assert.equal(t[1].haken, false)
}
} finally {
['termine', 'shop'].forEach((k, i) => {
if (alt[i] === undefined) delete config.features[k].bestellpruefung
else config.features[k].bestellpruefung = alt[i]
})
}
assert.match(consentAbo('shop').items[1].text, /eigenem Namen.*eigenes Stripe-Konto/)
assert.match(consentAbo('termine').items[1].text, /Gesundheitsbereich/)
await leere('featureConsents')
assert.equal((await go('news')).status, 200)
assert.deepEqual(JSON.parse([...aw.docs('featureConsents').values()][0].texte).map((x) => x.id), ['einschalten-zeile', 'knopf'])
})
test('Probezeit (Privatkunde bestellte das Abo ohne Häkchen): abo_fehlt beginnt_spaeter mit Datum statt Zahlungsmeldung', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-11-03T05:00:00.000Z' }))
const r = await go('news')
assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel, r.body.ab], [409, 'abo_fehlt', 'beginnt_spaeter', false, '2026-11-03T05:00:00.000Z'])
assert.equal(r.body.error, 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.')
assert.doesNotMatch(r.body.error, /Zahlung/)
nichts()
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-10-01T05:00:00.000Z' })) // Probezeit vorbei, Stripe noch nicht nachgezogen
assert.equal((await go('news')).body.grund, 'nicht_aktiv')
})
test('Doppelt und widersprüchlich: nacheinander, gleichzeitig, zweite Website, Zustände', async () => {
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
assert.deepEqual([(await go('news')).status, (await go('news')).body.code], [200, 'bereits_eingeschaltet'])
assert.deepEqual([aw.docs('workorders').size, aw.docs('featureConsents').size], [1, 1])
assert.equal((await go('news', { projectId: 'p1b' })).status, 200)
assert.equal(aw.docs('workorders').size, 2) // zweite Website, eigenes Ticket
const gl = await Promise.all([go('news', { projectId: 'p1c' }), go('news', { projectId: 'p1c' })])
assert.deepEqual(gl.map((x) => x.status).sort(), [200, 409])
assert.equal(aw.docs('workorders').size, 3)
await updateDocument('portalFeatures', 'f_p1_news', { state: 'active' })
assert.equal((await go('news')).body.code, 'bereits_eingeschaltet')
await updateDocument('portalFeatures', 'f_p1_news', { state: 'ordered' })
assert.equal((await go('news')).body.code, 'bestellung_laeuft')
await updateDocument('portalFeatures', 'f_p1_news', { state: 'disabled' })
assert.equal((await go('news')).body.code, 'abgeschaltet')
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'setup', unlockedBy: 'abo', unlockedByPurchase: false })
assert.equal((await go('news')).status, 200) // setup ohne Ticket: Heilung, kein 409
})
test('Abo-Grenzfälle', async () => {
const faelle = [
['Hosting + Wartungs-Funktion', hostingDoc, 'falsches_abo', true],
['gekündigt', { ...wartungDoc, cancelAtPeriodEnd: true }, 'gekuendigt', false],
['past_due', { ...wartungDoc, status: 'past_due' }, 'nicht_aktiv', false],
['beendet', { ...wartungDoc, status: 'canceled' }, 'kein_abo', false],
]
for (const [name, doc, grund, wechsel] of faelle) {
aw.seed('subscriptions', subscriptionDoc('sub_h', doc))
const r = await go('termine')
assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel], [409, 'abo_fehlt', grund, wechsel], name)
}
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
config.features.news.bestellpruefung = async () => {
await leere('subscriptions') // Abo endet nach der Vorprüfung, vor der Sperre
return []
}
try {
const spaet = await go('news')
assert.deepEqual([spaet.status, spaet.body.code], [409, 'abo_fehlt'])
nichts()
} finally {
delete config.features.news.bestellpruefung
}
})
test('Abo nicht lesbar: 503 mit Text, nichts geschrieben', async () => {
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
const r = await go('news')
assert.deepEqual([r.status, r.body.error], [503, EINSCHALTEN_TEXTE.nichtPruefbar])
nichts()
})
test('Eingabe und Archiv', async () => {
const faelle = [
[{ projectId: '' }, 400],
[{ zustimmung: undefined }, 400],
[{ zustimmung: { version: 'x', haken: 'beginn' } }, 400],
[{ zustimmung: { version: 7, haken: [] } }, 400],
[{ zustimmung: Z('news', ['x']) }, 400],
[{ projectId: 'p_fremd' }, 403],
[{ projectId: 'p_archiv' }, 409],
]
for (const [body, status] of faelle) assert.equal((await go('news', body)).status, status, JSON.stringify(body))
assert.equal((await go('news', { zustimmung: undefined })).body.code, 'zustimmung_fehlt')
nichts()
})
test('Unterlagen nicht abrufbar: 503 unterlagen_fehlen vor dem Abo', async () => {
config.features.news.unterlagenBereit = async () => false
const r = await go('news')
assert.deepEqual([r.status, r.body.code], [503, 'unterlagen_fehlen'])
nichts()
})
+160
View File
@@ -0,0 +1,160 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import { useTestEnv } from './support/testEnv.js'
// Rechtstexte der Funktionen in Fassung 6 (Entwurf): Leistungsbeschreibungen und AVV ohne Preise (Tasks 7 bis 10).
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { render, funktionen6Dokumente } = await import('../scripts/build-legal-html.mjs')
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const lies = (rel) => readFileSync(join(ROOT, rel), 'utf8')
const ENTWURF = true
const B = { // Bausteine; {N} = Nominativ, {G} = Genitiv der Partei (Kunde/Kunden, Händler/Händlers, Vermieter/Vermieters)
geltung: 'Diese Leistungsbeschreibung gilt, wenn der {N} die Funktion im Kundenportal einschaltet und die dort angezeigte Zeile mit dem Klick auf „Einschalten“ bestätigt. Sie gilt für Funktionen, die ab ihrer Veröffentlichung eingeschaltet werden; hat der {N} die Funktion zuvor einzeln gekauft, gilt für sie weiter die Leistungsbeschreibung, unter der er sie gekauft hat.',
voraussetzung: 'Die Funktion ist Teil des Wartungs-Abos. Sie setzt ein laufendes Wartungs-Abo voraus; ein Hosting-Abo genügt nicht.',
einrichtung: 'Die Einrichtung der Funktion rechnet WEBklar als Arbeitszeit auf das Stundenkontingent des Wartungs-Abos an (§ 4 der Abo-Bedingungen). Reicht das verfügbare Kontingent nicht aus, führt WEBklar die Einrichtung trotzdem aus. Die fehlende Zeit ist ein Vorschuss, den die folgenden Monatskontingente ausgleichen. Eine zusätzliche Rechnung gibt es dafür nicht. Endet das Wartungs-Abo, solange noch ein Vorschuss offen ist, verfällt der Rest zugunsten des {G}.',
projekt: 'Hat WEBklar die Funktion auf Wunsch des {G} schon bei der Erstellung seiner Website eingebaut, richtet sich die Abrechnung der Einrichtung nach der Vereinbarung zum Projekt und nicht nach dem Stundenkontingent. Auch dann läuft die Funktion nur, solange das Wartungs-Abo läuft.',
mehraufwand: 'Arbeiten, die nicht zur Einrichtung gehören, führt WEBklar nur nach Absprache vor der Ausführung und nach Freigabe des {G} in Textform aus; es gilt § 4 der Abo-Bedingungen.',
widerruf: ['Das Einschalten der Funktion ist Teil des Abos und kein eigener Vertrag. Für den Widerruf gilt die Widerrufsbelehrung der Abo-Bedingungen. Widerruft ein Verbraucher das Abo oder den Wechsel ins Wartungs-Abo, endet die Funktion.',
'Hat der {N} die Funktion zuvor einzeln gekauft, bleibt die Widerrufsbelehrung maßgeblich, die er bei der Bestellung erhalten hat.'],
ende: 'Die Funktion läuft, solange das Wartungs-Abo läuft. Endet es, auch durch einen Wechsel ins Hosting-Abo oder durch den Widerruf eines Wechsels, schaltet WEBklar die Funktion spätestens am folgenden Tag ab.', // alle Funktionen außer Öffnungszeiten live (R8: Tagesjob)
endeZeiten: 'Die Funktion läuft, solange das Wartungs-Abo läuft. Endet es, auch durch einen Wechsel ins Hosting-Abo, läuft die Funktion noch 30 Tage unverändert weiter; danach schaltet WEBklar sie ab.', // nur Öffnungszeiten live: wie das bestehende Zeiten-Modul, P6 (Mail K40) und P4 (Text Z2); kein Behalten gegen Entgelt (R8, mit P4/P6 abgestimmt)
}
const PARTEI = { kunde: { N: 'Kunde', G: 'Kunden' }, haendler: { N: 'Händler', G: 'Händlers' }, vermieter: { N: 'Vermieter', G: 'Vermieters' } }
const bau = (t, p) => (Array.isArray(t) ? t.map((x) => bau(x, p)) : t.replaceAll('{N}', PARTEI[p].N).replaceAll('{G}', PARTEI[p].G))
// „Haken“ der Gäste im Widget (Terminplaner) und „Widerrufsbelehrung für Gäste“ bleiben erlaubt; verboten sind die Wörter der alten Bestellung
const VERBOTEN = /€|per Haken|Haken diesem|Zahlungspflichtig|Einrichtungspreis|Gesamtpreis|Bestellung der Einrichtung|im Kundenportal bestellt|Einrichtung von Funktionen|ohne Wartungs-Abo|auch im Hosting-Abo|Hosting-Abo, Wartungs-Abo oder|Vertrag über den Betrieb (der|seiner) Website|solange WEBklar die Website|[a-zäöüß,;:] Sie\b|\bIhr(e[nmrs]?)?\b/ // „Sie“ am Satzanfang ist das Pronoun für die Funktion („Sie setzt ein laufendes Wartungs-Abo voraus“), erlaubt
const LISTE = ['termine', 'news', 'zeiten', 'shop', 'unterkunft'] // Task 9 ergänzt analyse
const LB = { // frist = Satzteil, der die Löschfrist nennt (C12); pflicht = funktionsspezifischer Pflichtsatz
termine: { slug: 'leistungsbeschreibung-terminplaner', partei: 'kunde', alt: 'docs/rechtliches/leistungsbeschreibung-terminplaner.md', frist: '30 Tage nach dem Abschalten',
pflicht: 'Der Fragebogen fragt die Branche ab; wählt der Kunde den Gesundheitsbereich, ist der Terminplaner für ihn nicht verfügbar.' },
news: { slug: 'leistungsbeschreibung-news', partei: 'kunde', frist: 'Beiträge und Fotos bleiben nach dem Abschalten 30 Tage gespeichert und werden danach gelöscht; beginnt in dieser Zeit wieder ein Wartungs-Abo, schaltet WEBklar die Pinnwand mit den vorhandenen Beiträgen ohne weitere Kosten wieder ein.',
pflicht: 'Der Kunde ist für die Inhalte seiner Beiträge und Fotos verantwortlich. Er versichert, dass er die nötigen Rechte an Texten und Fotos hat und dass die Inhalte keine Rechte Dritter verletzen, etwa Urheberrechte, Persönlichkeitsrechte oder das Recht am eigenen Bild abgebildeter Personen.',
nr: ['1. Geltung', '2. Leistung', '3. Einrichtung', '4. Pflichten des Kunden', '5. Ende der Leistung', '6. Widerruf'] },
zeiten: { slug: 'leistungsbeschreibung-zeiten', partei: 'kunde', frist: 'Die Eingaben des Kunden bleiben nach dem Abschalten 30 Tage gespeichert und werden danach gelöscht; beginnt schon in den 30 Tagen Weiterlauf wieder ein Wartungs-Abo, läuft die Funktion einfach weiter, beginnt es in den 30 Tagen danach, schaltet WEBklar die Funktion mit den vorhandenen Eingaben ohne weitere Kosten wieder ein.',
nr: ['1. Geltung', '2. Leistung', '3. Einbau und Freischaltung', '4. Einrichtung', '5. Nicht enthalten', '6. Pflichten des Kunden', '7. Verfügbarkeit und Aktualität', '8. Haftung', '9. Ende der Leistung', '10. Änderungen und Einstellung', '11. Widerruf', '12. Hinweise'] },
}
LB.shop = { slug: 'leistungsbeschreibung-webshop', partei: 'haendler', alt: 'docs/rechtliches/leistungsbeschreibung-webshop.md', frist: '90 Tage nach dem Abschalten',
pflicht: 'Der Händler verkauft in eigenem Namen. Für seine Produkte, Preise, Produktangaben und Rechtstexte (Allgemeine Geschäftsbedingungen, Widerrufsbelehrung, Impressum, Datenschutzerklärung) ist er selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Er verkauft keine der in Nr. 3 ausgeschlossenen Waren. Die Zahlungen seiner Käufer laufen über sein eigenes Stripe-Konto zu den Bedingungen von Stripe; WEBklar erhält und verwahrt kein Geld seiner Käufer und erhebt keine Umsatzbeteiligung.',
nurBetrieb: 'Der Webshop steht nur Kunden zur Verfügung, deren Kundenart „Betrieb“ ist.' }
LB.unterkunft = { slug: 'leistungsbeschreibung-unterkunft', partei: 'vermieter', alt: 'docs/rechtliches/leistungsbeschreibung-unterkunft.md', frist: '90 Tage nach dem Abschalten',
pflicht: 'Der Vermieter vermietet in eigenem Namen; für Preise, Stornobedingungen, Rechnungen und Meldepflichten ist er verantwortlich. Online-Zahlungen seiner Gäste laufen, sofern er sie nutzt, über sein eigenes Stripe-Konto zu den Bedingungen von Stripe; WEBklar erhält und verwahrt kein Geld seiner Gäste und erhebt keine Umsatzbeteiligung. Rechnungen an Gäste stellt WEBklar nur aus, wenn der Vermieter in den Einstellungen des Buchungskalenders den Rechnungsauftrag erteilt hat (§ 14 Abs. 2 Satz 4 UStG); für die Richtigkeit von Preisen und Steuerangaben bleibt er verantwortlich.' }
const abschnitte = (md) => md.split(/^## /m).slice(1).map((s) => ({ kopf: s.split('\n')[0], absaetze: s.split(/\n\n/).filter((b) => /^\(\d+\) /.test(b)) }))
test('Leistungsbeschreibungen v6: Fassungszeile, Bausteine, Pflichtsatz, Frist, keine Preise', () => {
for (const key of LISTE) {
const e = LB[key], md = lies(`docs/rechtliches/${e.slug}-v6.md`)
assert.match(md.split('\n')[2], new RegExp(`^\\*\\*Fassung 2026-10-16\\*\\*${ENTWURF ? ' · Entwurf – Prüfung durch Kanzlei offen' : ''} · Zusatzbedingungen zum Wartungs-Abo$`), key)
assert.doesNotMatch(md, VERBOTEN, key)
for (const t of ['geltung', 'voraussetzung', 'einrichtung', 'projekt', 'mehraufwand', key === 'zeiten' ? 'endeZeiten' : 'ende']) assert.ok(md.includes(bau(B[t], e.partei)), `${key}: Baustein ${t}`)
if (key === 'zeiten') assert.doesNotMatch(md, /Karenz|Behalten|spätestens am folgenden Tag/, 'Zeiten: 30 Tage Weiterlauf wie das bestehende Modul, kein Behalten, nicht die Abschaltung am Folgetag')
for (const t of bau(B.widerruf, e.partei)) assert.ok(md.includes(t), `${key}: Baustein widerruf`)
if (e.pflicht) assert.ok(md.includes(e.pflicht), `${key}: Pflichtsatz`)
assert.ok(md.includes(e.frist), `${key}: Frist`)
if (e.nr) assert.deepEqual(abschnitte(md).map((a) => a.kopf), e.nr, key)
}
})
test('Terminplaner v6: Überschriften wie Fassung 5 (Nr. 5 heißt „Einrichtung“), Absatzzahlen, auf die der AVV verweist', () => {
const v5 = abschnitte(lies(LB.termine.alt)), v6 = abschnitte(lies('docs/rechtliches/leistungsbeschreibung-terminplaner-v6.md'))
assert.deepEqual(v6.map((a) => a.kopf), v5.map((a) => (a.kopf === '5. Einrichtung und Preis' ? '5. Einrichtung' : a.kopf)))
assert.deepEqual(v6.map((a) => a.absaetze.length), v5.map((a) => (a.kopf.startsWith('10. ') ? 2 : a.absaetze.length)), 'Absatzzahlen wie Fassung 5, Nr. 10 hat die zwei Absätze des Bausteins widerruf')
assert.ok(v6[7].absaetze[0].includes(bau(B.ende, 'kunde')), 'Nr. 8 Abs. 1 ist das Ende (AVV Terminplaner Nr. 1 Abs. 2)')
})
test('Seiten der Gruppe sind genau render(Markdown)', () => {
for (const key of LISTE) {
const d = funktionen6Dokumente().find((x) => x.html.includes(`/${LB[key].slug}-2026-10-16.html`))
assert.equal(lies(d.html), render(lies(d.md), d.meta), `${d.html}: node scripts/build-legal-html.mjs funktionen6 --nur=${d.doc}`)
}
})
test('Entwurfsseiten der Gruppe: Entwurfsmarke in der Quelle und meta robots noindex in der Seite (N45)', () => {
for (const key of LISTE) {
const d = funktionen6Dokumente().find((x) => x.html.includes(`/${LB[key].slug}-2026-10-16.html`))
assert.equal(lies(d.md).includes('Entwurf – Prüfung durch Kanzlei offen'), ENTWURF, d.md)
assert.equal(lies(d.html).includes('<meta name="robots" content="noindex">'), ENTWURF, d.html)
}
})
test('Webshop und Buchungskalender v6: Überschriften wie Fassung 5, Nr. 9 neu benannt, nur Wartungs-Abo, Ende mit dem Abo', () => {
for (const key of ['shop', 'unterkunft']) {
const e = LB[key], v5 = abschnitte(lies(e.alt)), md = lies(`docs/rechtliches/${e.slug}-v6.md`), v6 = abschnitte(md)
assert.deepEqual(v6.map((a) => a.kopf), v5.map((a) => (a.kopf.startsWith('9. Preis') ? '9. Einrichtung, Abrechnung und Widerruf' : a.kopf)), key)
const n9 = v6.find((a) => a.kopf.startsWith('9. '))
assert.equal(n9.absaetze.length, 5, `${key}: Nr. 9 hat fünf Absätze (1 Einrichtung, 2 Dauer, 3 Projektstart, 4 Mehraufwand, 5 Widerruf)`)
assert.ok(n9.absaetze[2].includes(bau(B.projekt, e.partei)) && n9.absaetze[4].includes(bau(B.widerruf, e.partei)[0]), key)
assert.ok(n9.absaetze[0].includes('keine laufende Gebühr'), key)
assert.ok([...md.matchAll(/Nr\. 9 Abs\. (\d+)/g)].every((m) => Number(m[1]) <= 5), `${key}: Verweise auf Nr. 9 treffen`)
}
assert.ok(lies(`docs/rechtliches/${LB.shop.slug}-v6.md`).includes(LB.shop.nurBetrieb))
})
const BA = {
voraussetzung: 'Die Funktion ist in beiden Abos enthalten. Sie setzt ein laufendes Hosting-Abo oder Wartungs-Abo voraus.',
einrichtung: 'Die Einrichtung der Website-Analyse ist für den Kunden kostenlos. WEBklar rechnet sie weder auf ein Stundenkontingent an noch berechnet WEBklar sie gesondert.',
ende: 'Die Website-Analyse läuft, solange das Abo läuft, das sie enthält. Endet es, schaltet WEBklar sie spätestens am folgenden Tag ab. Wechselt der Kunde vom Wartungs- ins Hosting-Abo, bleibt die Website-Analyse; das Gleiche gilt, wenn er den Wechsel ins Wartungs-Abo widerruft.',
widerruf: 'Das Einschalten der Website-Analyse ist Teil des Abos und kein eigener Vertrag. Für den Widerruf gilt die Widerrufsbelehrung der Abo-Bedingungen.',
}
test('Website-Analyse v6 (Leistungsbeschreibung): in beiden Abos, Einrichtung kostenlos, Ende mit dem Abo, Zielgruppenregel bleibt', () => {
const md = lies('docs/rechtliches/leistungsbeschreibung-website-analyse-v6.md')
assert.match(md.split('\n')[2], /^\*\*Fassung 2026-10-16\*\* · (Entwurf – Prüfung durch Kanzlei offen · )?Zusatzbedingungen zum Hosting-Abo und zum Wartungs-Abo$/)
assert.doesNotMatch(md, /€|per Haken|[a-zäöüß,;:] Sie\b|Im Hosting-Abo ist die Website-Analyse nicht enthalten|Sie ist im Wartungs-Abo ohne Aufpreis|im Kundenportal bestellt/)
assert.ok(md.includes(B.geltung.replaceAll('{N}', 'Kunde')))
for (const t of Object.values(BA)) assert.ok(md.includes(t), t.slice(0, 40))
for (const s of ['Sie ist im Hosting-Abo und im Wartungs-Abo ohne Aufpreis enthalten.', 'Der Kunde hat ein laufendes Hosting-Abo oder Wartungs-Abo.',
'Der Kunde kann das Abo dann innerhalb von 30 Tagen nach der Mitteilung zum Zeitpunkt der Einstellung kündigen',
'Weil für die Website-Analyse kein Aufpreis berechnet wird, ändert ihre Einstellung den Preis des Abos nicht.',
'Richtet sich die Website auch gezielt an Kinder unter 16 Jahren']) assert.ok(md.includes(s), s) // letzter Satz: Zielgruppenregel (Nr. 3 Abs. 4) bleibt
assert.deepEqual(abschnitte(md).map((a) => a.kopf), abschnitte(lies('docs/rechtliches/leistungsbeschreibung-website-analyse.md')).map((a) => a.kopf))
})
LB.analyse = { slug: 'leistungsbeschreibung-website-analyse', partei: 'kunde' } // nur für die Querverweise in Task 10
const AVV = { // [alt, neu] je Datei; der Rest ab "## 1." ist sonst wortgleich mit der Bestandsfassung
analyse: [['spätestens mit dem Ende des Wartungs-Abos; den Messdienst schaltet WEBklar dann spätestens am folgenden Tag ab', 'spätestens mit dem Ende des Abos, das die Website-Analyse enthält (Hosting-Abo oder Wartungs-Abo); endet nur das Wartungs-Abo und läuft ein Hosting-Abo weiter, läuft dieser Vertrag weiter. Den Messdienst schaltet WEBklar bei Vertragsende spätestens am folgenden Tag ab'],
['(Ausschalten oder Ende des Wartungs-Abos)', '(Ausschalten oder Ende des Abos, das sie enthält)'],
['Das Wartungs-Abo bleibt davon unberührt.', 'Das Abo bleibt davon unberührt.']],
}
const KOPF_AVV = ENTWURF ? '**Fassung 2026-10-16** · Entwurf – Prüfung durch Kanzlei offen' : '**Fassung 2026-10-16**'
const AVV_FUNKTION = { termine: 'Terminplaner', shop: 'Webshop', unterkunft: 'Buchungskalender', analyse: 'Website-Analyse' }
const avvVorbemerkung = (funktion, fassung) => `Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Funktion „${funktion}“ einschaltet und die dort angezeigte Zeile „Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${fassung})“ mit dem Klick auf „Einschalten“ bestätigt; diesem Vertrag und der Leistungsbeschreibung „${funktion}“ („Leistungsbeschreibung“) in der angezeigten Fassung stimmt er damit zu. Das Portal speichert den Wortlaut der angezeigten Zeile, die Fassungen, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte in dieser Fassung als PDF enthält und auf sie verlinkt.`
function pruefeAvv(key, slug) {
const v5 = lies(`docs/rechtliches/${slug}.md`), v6 = lies(`docs/rechtliches/${slug}-v6.md`)
assert.equal(v6.split('\n')[2], KOPF_AVV)
assert.ok(v6.includes(`(1) ${avvVorbemerkung(AVV_FUNKTION[key], '2026-10-16')}`))
assert.doesNotMatch(v6, /per Haken|Mit der Bestellung weist|Vertrag über den Betrieb der Website|keine Bestellung geworden/)
let erwartet = v5.replace(/^\*\*Fassung \d{4}-\d{2}-\d{2}\*\*.*$/m, KOPF_AVV)
erwartet = erwartet.replace(/^\(1\) Der Kunde schließt diesen Vertrag[^\n]*$/m, `(1) ${avvVorbemerkung(AVV_FUNKTION[key], '2026-10-16')}`)
for (const [alt, neu] of AVV[key]) { assert.ok(erwartet.includes(alt), `Bestand enthält: ${alt.slice(0, 40)}`); erwartet = erwartet.replaceAll(alt, neu) }
assert.equal(v6, erwartet, `${slug}-v6.md = Bestandsfassung mit genau diesen Ersetzungen`)
}
test('AVV Website-Analyse v6', () => { pruefeAvv('analyse', 'avv-website-analyse') })
test('Analyse-Seiten sind genau render(Markdown)', () => {
for (const doc of ['analyse-lb', 'analyse-avv']) {
const d = funktionen6Dokumente().find((x) => x.doc === doc)
assert.equal(lies(d.html), render(lies(d.md), d.meta), d.html)
}
})
AVV.termine = [['Mit der Bestellung weist der Kunde WEBklar an', 'Mit dem Einschalten weist der Kunde WEBklar an'], ['aus denen keine Bestellung geworden ist', 'aus denen die Funktion nicht eingeschaltet wurde']]
for (const [key, name] of [['shop', 'den Webshop'], ['unterkunft', 'den Buchungskalender']]) {
AVV[key] = [['Mit der Bestellung weist der Kunde WEBklar an', 'Mit dem Einschalten weist der Kunde WEBklar an'],
[`spätestens mit dem Ende des Vertrags, aufgrund dessen WEBklar die Website des Kunden betreibt; ${name} schaltet WEBklar dann zum Vertragsende ab`, `spätestens mit dem Ende des Wartungs-Abos; ${name} schaltet WEBklar dann spätestens am folgenden Tag ab`],
['oder der Vertrag über den Betrieb der Website beendet', 'oder das Wartungs-Abo beendet'],
['wieder ein Vertrag über den Betrieb der Website', 'wieder ein Wartungs-Abo']] // zweite Fundstelle in Nr. 9 Abs. 6 (Wiedereinschalten in der Frist)
}
test('AVV Terminplaner, Webshop, Buchungskalender v6: Bestandsfassung mit genau den Ersetzungen', () => {
pruefeAvv('termine', 'avv-terminplaner'); pruefeAvv('shop', 'avv-webshop'); pruefeAvv('unterkunft', 'avv-unterkunft')
})
test('Querverweise zwischen AVV und Leistungsbeschreibung treffen', () => {
for (const [key, avv] of [['termine', 'avv-terminplaner'], ['shop', 'avv-webshop'], ['unterkunft', 'avv-unterkunft'], ['analyse', 'avv-website-analyse']]) {
const lb = abschnitte(lies(`docs/rechtliches/${LB[key].slug}-v6.md`)), text = lies(`docs/rechtliches/${avv}-v6.md`)
for (const [ref, n, a] of text.matchAll(/Nr\. (\d+)(?: Abs\. (\d+))? der Leistungsbeschreibung/g)) {
const abschnitt = lb.find((x) => x.kopf.startsWith(`${n}. `)); assert.ok(abschnitt, `${avv}: ${ref}`)
if (a) assert.ok(Number(a) <= abschnitt.absaetze.length, `${avv}: ${ref}`)
}
}
})
test('alle zehn Seiten sind genau render(Markdown), --check meldet nichts', () => {
const lauf1 = spawnSync(process.execPath, ['--max-old-space-size=256', 'scripts/build-legal-html.mjs', 'funktionen6', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
assert.equal(lauf1.status, 0, lauf1.stdout + lauf1.stderr)
assert.equal([...lauf1.stdout.matchAll(/^ok /gm)].length, 10)
})
+52
View File
@@ -0,0 +1,52 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
const aw = await startAppwriteMock()
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { computeLedger, listPlanUsage, getLedger } = await import('../server/services/hourLedger.js')
const K = (id, minutes, grantedAt) => ({ $id: id, minutes, grantedAt, expiresAt: '2099-01-01T00:00:00.000Z' })
const U = (id, tag, minutes, einrichtung = false) => ({ id, date: `2026-${tag}T12:00:00.000Z`, minutes, einrichtung })
const NOW = new Date('2026-12-01T00:00:00.000Z')
const je = (l) => Object.fromEntries(l.usage.map((e) => [e.id, [e.coveredMinutes, e.overageMinutes, e.vorschussMinutes]])) // [gedeckt, Mehraufwand, Vorschuss]
test('Einrichtung ohne volles Guthaben: Vorschuss, nie Mehraufwand; normale Arbeit bleibt Mehraufwand', () => {
const k = [K('k1', 60, '2026-09-01T00:00:00.000Z')]
const e = computeLedger(k, [U('e1', '09-10', 150, true)], { now: NOW })
assert.deepEqual(je(e), { e1: [60, 0, 90] }); assert.deepEqual([e.vorschussMinutes, e.overageMinutes, e.balanceMinutes, e.usedMinutes], [90, 0, 0, 60])
const n = computeLedger(k, [U('n1', '09-10', 90)], { now: NOW })
assert.deepEqual(je(n), { n1: [60, 30, 0] }); assert.deepEqual([n.vorschussMinutes, n.overageMinutes], [0, 30])
})
test('spätere Gutschrift gleicht zuerst den Vorschuss aus, vor jeder anderen Arbeit', () => {
const use = [U('n1', '09-05', 60), U('n2', '09-06', 60), U('e1', '09-10', 60, true)]
const [k1, k2, k3] = [K('k1', 60, '2026-09-01T00:00:00.000Z'), K('k2', 60, '2026-10-01T00:00:00.000Z'), K('k3', 60, '2026-11-01T00:00:00.000Z')]
assert.deepEqual(je(computeLedger([k1], use, { now: NOW })), { n1: [60, 0, 0], n2: [0, 60, 0], e1: [0, 0, 60] }) // beides sichtbar
assert.deepEqual(je(computeLedger([k1, k2], use, { now: NOW })), { n1: [60, 0, 0], n2: [0, 60, 0], e1: [60, 0, 0] }) // k2 deckt e1 vor n2
const voll = computeLedger([k1, k2, k3], use, { now: NOW })
assert.deepEqual(je(voll), { n1: [60, 0, 0], n2: [60, 0, 0], e1: [60, 0, 0] }); assert.deepEqual([voll.overageMinutes, voll.vorschussMinutes, voll.balanceMinutes], [0, 0, 0])
})
test('ohne Einrichtung bleibt die Zuordnung wie bisher (Eintrag für Eintrag, rückwirkend)', () => {
const l = computeLedger([K('a', 30, '2026-01-01T00:00:00.000Z'), K('c', 60, '2026-01-15T00:00:00.000Z')], [U('n1', '01-10', 60), U('n2', '01-20', 60)], { now: NOW })
assert.deepEqual(je(l), { n1: [60, 0, 0], n2: [30, 30, 0] }); assert.deepEqual([l.overageMinutes, l.vorschussMinutes], [30, 0])
})
test('Vertragsende (active false): Vorschuss bleibt stehen, Guthaben 0', () => {
const l = computeLedger([K('k1', 60, '2026-09-01T00:00:00.000Z')], [U('e1', '09-10', 150, true)], { now: NOW, active: false })
assert.deepEqual([l.vorschussMinutes, l.balanceMinutes, l.nextExpiry], [90, 0, null])
})
test('listPlanUsage und getLedger: Einrichtung nur bei einrichtungAbrechnung abo; Git-Blatt zählt mit; projekt und fehlendes Feld nicht', async () => {
aw.seed('workorders', { $id: 'wo_e', customerId: 'c9', woid: '100301', topic: 'Einrichtung', einrichtungAbrechnung: 'abo' })
aw.seed('workorders', { $id: 'wo_p', customerId: 'c9', woid: '100302', topic: 'Projekt', einrichtungAbrechnung: 'projekt' })
aw.seed('workorders', { $id: 'wo_n', customerId: 'c9', woid: '100303', topic: 'Alt' })
const ws = (id, wo, tag, over = {}) => aw.seed('worksheets', { $id: id, workorderId: wo, wsid: id, countsToPlan: true, totalTime: 60, startDate: `${tag}.10.2026`, employeeName: 'Justin', details: 'x', ...over })
ws('s_e', 'wo_e', '05'); ws('s_git', 'wo_e', '06', { serviceType: 'GIT', isComment: true, totalTime: 30, employeeShort: 'GIT' })
ws('s_p', 'wo_p', '07'); ws('s_n', 'wo_n', '08'); ws('s_aus', 'wo_e', '09', { countsToPlan: false })
assert.deepEqual(Object.fromEntries((await listPlanUsage('c9')).map((x) => [x.id, x.einrichtung])), { s_e: true, s_git: true, s_p: false, s_n: false })
aw.seed('hourCredits', { ...K('h1', 60, '2026-09-01T00:00:00.000Z'), customerId: 'c9' })
const l = await getLedger('c9') // 60 min gedeckt (s_e), s_git 30 min Vorschuss, s_p und s_n je 60 min Mehraufwand
assert.deepEqual([l.usedMinutes, l.vorschussMinutes, l.overageMinutes], [60, 30, 120])
})
+43
View File
@@ -0,0 +1,43 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
// Entscheidungsliste, Kanzlei-Fragen, Ablauf nach der Freigabe und Datenschutz-Prüfpunkte der Rechtsrunde Fassung 6 (Task 11).
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
const ENTWURF = true
const md = lies('docs/rechtliches/kundenansicht-entscheidungen.md'), dsg = lies('docs/rechtliches/datenschutz-kundenansicht-pruefpunkte.md')
test('Entscheidungsliste: R1 bis R17, je Punkt Vorschlag, Alternative und Kanzlei-Frage', () => {
const z = md.split('\n').filter((l) => /^\| R\d+ \|/.test(l)).map((l) => l.split('|').slice(1, -1).map((s) => s.trim()))
assert.deepEqual(z.map((r) => r[0]), Array.from({ length: 17 }, (_, i) => `R${i + 1}`))
for (const r of z) { assert.equal(r.length, 5); assert.ok(r.every(Boolean), r[0]); assert.match(r[4], /^(–|KQ\d+(, KQ\d+)*)$/, r[0]) }
const kq = [...md.matchAll(/^### (KQ\d+): .+$/gm)].map((m) => m[1])
assert.deepEqual(kq, Array.from({ length: 9 }, (_, i) => `KQ${i + 1}`))
for (const r of z) for (const id of r[4].match(/KQ\d+/g) ?? []) assert.ok(kq.includes(id), `${r[0]} nennt ${id}`)
assert.equal(md.includes('Entwurf zur Freigabe, keine Rechtsberatung'), ENTWURF)
})
test('R8 ist mit P4/P6 abgestimmt: Zeiten laufen 30 Tage weiter, die übrigen schalten am Folgetag ab, kein Behalten', () => {
const [, punkt, vorschlag, alternative] = md.split('\n').find((l) => l.startsWith('| R8 |')).split('|').slice(1, -1).map((s) => s.trim())
assert.match(punkt, /mit P4\/P6 abgestimmt/)
for (const s of ['spätestens am Folgetag', 'Öffnungszeiten live läuft nach dem Ende noch 30 Tage unverändert weiter', 'kein Behalten']) assert.ok(vorschlag.includes(s), s)
assert.doesNotMatch(vorschlag, /Karenz|entfällt/)
assert.ok(alternative.includes('Öffnungszeiten live schalten wie die übrigen spätestens am Folgetag ab'))
})
test('Kanzlei-Fragen haben vier Teile', () => {
for (const teil of md.split(/\n(?=### KQ\d+:)/).slice(1)) for (const k of ['**Frage.**', '**Fundstelle im Text.**', '**Unsere Annahme.**', '**Alternative.**']) assert.ok(teil.includes(k), `${teil.slice(0, 8)} ${k}`)
})
test('AVV-Struktur nennt A, B und C; Ablauf nach der Freigabe in fester Reihenfolge mit Schutzbefehlen', () => {
const avv = md.slice(md.indexOf('## AVV-Struktur'), md.indexOf('## Ablauf nach der Freigabe'))
for (const s of ['Option A', 'Option B', 'Option C', 'Anlage 1']) assert.ok(avv.includes(s), s)
const a = md.slice(md.indexOf('## Ablauf nach der Freigabe'))
const reihenfolge = ['Freigabe des Inhabers', 'ENTWURF = false', 'scripts/build-legal-html.mjs funktionen6', 'free -m', 'nice -n 10 scripts/build-legal-pdfs.sh abo6', 'nice -n 10 scripts/build-legal-pdfs.sh funktionen6', 'rechtliches-hochladen.mjs --probe', '--karten-anlegen', 'ein Aufruf je Dokument', '--freigabe=', '--pruefen']
const pos = reihenfolge.map((s) => { const i = a.indexOf(s); assert.ok(i >= 0, s); return i })
assert.deepEqual([...pos].sort((x, y) => x - y), pos, 'Reihenfolge')
assert.ok(a.includes('Datum der Fassungen') && a.includes('Entwurfsmarke'), 'Datum setzen und Marken entfernen')
})
test('Datenschutz-Prüfpunkte D1 bis D8 mit gültiger Fundstelle in public/datenschutz.html', () => {
assert.deepEqual([...dsg.matchAll(/^\*\*(D\d)\*\*/gm)].map((m) => m[1]), ['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8'])
const zeilen = lies('public/datenschutz.html').split('\n')
const refs = [...dsg.matchAll(/public\/datenschutz\.html:(\d+)/g)].map((m) => Number(m[1]))
assert.ok(refs.length >= 8 && refs.every((n) => n >= 1 && n <= zeilen.length))
assert.ok(zeilen[119].includes('Zahlungspflichtig bestellen') && zeilen[118].includes('Leistungsbeginn') && zeilen[94].includes('Wartungs-Abo'), 'D1, D2, D5 zitieren die richtigen Zeilen')
})
+75
View File
@@ -0,0 +1,75 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { KUNDENANSICHT_ATTRIBUTE, main } from '../scripts/kundenansicht-setup.mjs'
// Setup-Skript der Kundenansicht neu (Paket P5): Kundenart im Nachweis, Einrichtungs-Marker an Ticket und Funktion.
// Das Skript laeuft hier nur gegen Mock-Clients, nie gegen Produktion.
const aw = await startAppwriteMock({ schema: {} })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const client = (vorhanden = {}) => {
const calls = { attrs: [], waits: [] }
return {
calls,
collectionExists: async () => true,
listAttributes: async (id) => (vorhanden[id] ?? []).map((key) => ({ key })),
createAttribute: async (id, a) => calls.attrs.push(`${id}.${a.key}`),
waitForAttribute: async (id, k) => calls.waits.push(`${id}.${k}`),
}
}
const ID = () => config.collections
const ALLE = () => [
`${ID().featureConsents}.kundenart`,
`${ID().portalFeatures}.einrichtungAbrechnung`,
`${ID().portalFeatures}.unlockedBy`,
`${ID().workorders}.einrichtungAbrechnung`,
`${ID().workorders}.einrichtungFuer`,
]
test('Schema: Schlüssel, Größen, nur optionale Strings; längste Werte passen hinein', () => {
const kurz = Object.fromEntries(Object.entries(KUNDENANSICHT_ATTRIBUTE).map(([c, l]) => [c, l.map((a) => `${a.key}:${a.size}`)]))
assert.deepEqual(kurz, {
featureConsents: ['kundenart:12'],
workorders: ['einrichtungFuer:16', 'einrichtungAbrechnung:8'],
portalFeatures: ['unlockedBy:8', 'einrichtungAbrechnung:8'],
})
assert.ok(Object.values(KUNDENANSICHT_ATTRIBUTE).flat().every((a) => a.type === 'string' && a.required === false))
assert.ok('unternehmen'.length <= 12 && 'projekt'.length <= 8 && 'unterkunft'.length <= 16)
})
test('--pruefen meldet alle fünf Lücken und legt nichts an', async () => {
const c = client()
const log = []
const r = await main({ pruefen: true, client: c, log: (z) => log.push(z) })
assert.deepEqual(r.missing.map((a) => `${a.collection}.${a.key}`).sort(), ALLE())
assert.deepEqual(c.calls, { attrs: [], waits: [] })
assert.ok(log.some((z) => z === `- ${ID().featureConsents}.kundenart FEHLT`))
})
test('ohne --pruefen: legt an und wartet; der zweite Lauf ist idempotent', async () => {
const c = client()
await main({ client: c, log: () => {} })
assert.deepEqual([...c.calls.attrs].sort(), ALLE())
assert.deepEqual([...c.calls.waits].sort(), ALLE())
const voll = client({
[ID().featureConsents]: ['kundenart'],
[ID().workorders]: ['einrichtungFuer', 'einrichtungAbrechnung'],
[ID().portalFeatures]: ['unlockedBy', 'einrichtungAbrechnung'],
})
assert.deepEqual((await main({ client: voll, log: () => {} })).missing, [])
assert.deepEqual(voll.calls.attrs, [])
})
test('schemaKeys liest das Skript; das Skript löscht nichts', () => {
assert.equal(schemaKeys('featureConsents').at(-1), 'kundenart')
const f = schemaKeys('portalFeatures')
assert.ok(f.includes('einrichtungAbrechnung'))
assert.equal(new Set(f).size, f.length)
assert.doesNotMatch(readFileSync(new URL('../scripts/kundenansicht-setup.mjs', import.meta.url), 'utf8'), /delete/i)
})
+52
View File
@@ -0,0 +1,52 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { KUNDENARTEN, kundenartGespeichert, kundenartWirksam, NUR_BETRIEB_TEXT } from '../server/services/kundenart.js'
// V4: Kundenart-Hilfen (Server ist die einzige Quelle) und die Felder in /api/auth/me und Login
const aw = await startAppwriteMock({ schema: { customerViewLog: schemaKeys('customerViewLog') } })
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, rawGet } = await import('./support/testApp.js')
const { kundenansichtCookie } = await import('./support/fixtures.js')
const { default: authRoutes } = await import('../server/routes/auth.js')
const app = await startTestApp({ '/api/auth': authRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
aw.addUser({ $id: 'u1', email: 'k@example.invalid', password: 'passwort-eins' })
aw.addUser({ $id: 'u2', email: 'k2@example.invalid', password: 'passwort-zwei' })
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', customerStatus: 'active', portalAccessEnabled: true, kundenart: 'unternehmen' })
aw.seed('customerPortalAccess', { $id: 'pa1', customerId: 'c1', enabled: true })
aw.seed('customers', { $id: 'c2', appwriteUserId: 'u2', email: 'k2@example.invalid', name: 'Kunde Zwei', customerStatus: 'active', portalAccessEnabled: true })
aw.seed('customerPortalAccess', { $id: 'pa2', customerId: 'c2', enabled: true })
test('nur exakte Werte zählen, alles andere ist leer bzw. privat', () => {
for (const doc of [undefined, null, {}, { kundenart: '' }, { kundenart: 'Unternehmen' }, { kundenart: ' privat' }, { kundenart: 'firma' }, { kundenart: 7 }, 'unternehmen']) {
assert.equal(kundenartGespeichert(doc), '')
assert.equal(kundenartWirksam(doc), 'privat')
}
assert.deepEqual([kundenartGespeichert({ kundenart: 'unternehmen' }), kundenartWirksam({ kundenart: 'unternehmen' })], ['unternehmen', 'unternehmen'])
assert.deepEqual([kundenartGespeichert({ kundenart: 'privat' }), kundenartWirksam({ kundenart: 'privat' })], ['privat', 'privat'])
assert.deepEqual(KUNDENARTEN, ['unternehmen', 'privat'])
assert.equal(NUR_BETRIEB_TEXT, 'Diese Funktion gibt es nur für Betriebe. Schreib uns im Chat, wenn du Fragen dazu hast.')
assert.ok(!NUR_BETRIEB_TEXT.includes('€'))
})
test('/api/auth/me und Login liefern kundenart und kundenartWirksam in allen drei Zweigen', async () => {
const login = await post('/api/auth/login', { email: 'k@example.invalid', password: 'passwort-eins' })
assert.equal(login.status, 200)
assert.deepEqual([login.body.customer.kundenart, login.body.customer.kundenartWirksam], ['unternehmen', 'unternehmen'])
const cookie = login.headers.getSetCookie().find((c) => c.startsWith(`${config.cookieName}=`)).split(';')[0] // echter Cookie des Logins
const me = await rawGet('/api/auth/me', { cookie })
assert.equal(me.body.authenticated, true)
assert.deepEqual([me.body.customer.kundenart, me.body.customer.kundenartWirksam], ['unternehmen', 'unternehmen'])
const leer = await rawGet('/api/auth/me', { cookie: kundenansichtCookie({ customerId: 'c2' }) }) // Kundenansicht-Zweig, c2 ohne Feld
assert.equal(leer.body.viewAs, true)
assert.deepEqual([leer.body.customer.kundenart, leer.body.customer.kundenartWirksam], ['', 'privat'])
const login2 = await post('/api/auth/login', { email: 'k2@example.invalid', password: 'passwort-zwei' })
assert.deepEqual([login2.body.customer.kundenart, login2.body.customer.kundenartWirksam], ['', 'privat'])
})
+64
View File
@@ -0,0 +1,64 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Kundenart in der Kundenverwaltung (V4): Pflicht beim Anlegen nur ab Schalter pilot, beim Aendern optional
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, send } = await import('./support/testApp.js')
const { default: customersRouter } = await import('../server/routes/admin/customers.js')
const app = await startTestApp({ '/api/admin/customers': customersRouter })
const urspruenglich = config.kundenansichtNeu.mode
after(() => {
config.kundenansichtNeu.mode = urspruenglich
return Promise.all([app.close(), aw.close(), st.close()])
})
const modus = (m) => { config.kundenansichtNeu.mode = m }
beforeEach(() => modus('pilot'))
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
const neu = (extra = {}) => ({ name: 'Bäckerei Müller', email: 'mueller@example.invalid', password: 'geheim-1234', ...extra })
test('pilot und on: Anlegen ohne oder mit ungültiger Kundenart: 400 kundenart_fehlt, nichts geschrieben', async () => {
for (const mode of ['pilot', 'on']) {
modus(mode)
for (const kundenart of [undefined, '', 'Unternehmen', 'firma', 5, null]) {
const res = await post('/api/admin/customers', neu({ kundenart }), admin)
assert.deepEqual([res.status, res.body], [400, { error: 'Kundenart ist erforderlich (unternehmen oder privat).', code: 'kundenart_fehlt' }], `${mode} ${String(kundenart)}`)
}
}
assert.equal(aw.docs('customers').size, 0)
})
test('Anlegen mit unternehmen oder privat; Antwort und Dokument tragen die Kundenart', async () => {
const a = await post('/api/admin/customers', neu({ kundenart: 'unternehmen' }), admin)
assert.deepEqual([a.status, a.body.customer.kundenart], [201, 'unternehmen'])
assert.equal(aw.docs('customers').get(a.body.customer.$id).kundenart, 'unternehmen')
const b = await post('/api/admin/customers', neu({ email: 'frau@example.invalid', kundenart: 'privat' }), admin)
assert.equal(b.body.customer.kundenart, 'privat')
})
test('Ändern: ohne Feld bleibt die Kundenart, gültig ändert sie, ungültig ist 400 ohne Änderung', async () => {
const id = [...aw.docs('customers').keys()][0]
assert.equal((await send('PATCH', `/api/admin/customers/${id}`, { body: { name: 'Neu' }, ...admin })).body.customer.kundenart, 'unternehmen')
assert.equal((await send('PATCH', `/api/admin/customers/${id}`, { body: { kundenart: 'privat' }, ...admin })).body.customer.kundenart, 'privat')
const bad = await send('PATCH', `/api/admin/customers/${id}`, { body: { kundenart: 'x' }, ...admin })
assert.deepEqual([bad.status, bad.body.code], [400, 'kundenart_fehlt'])
assert.equal(aw.docs('customers').get(id).kundenart, 'privat')
})
test('off (Schalter aus): Anlegen ohne Kundenart wie heute, kein Feld im Dokument; ungültig mitgeschickt bleibt 400', async () => {
// als letzter Test der Datei: legt Kunden an, die die Tests davor nicht sehen sollen
modus('off')
const a = await post('/api/admin/customers', neu({ email: 'off1@example.invalid' }), admin)
assert.deepEqual([a.status, a.body.customer.kundenart], [201, ''])
assert.equal(Object.hasOwn(aw.docs('customers').get(a.body.customer.$id), 'kundenart'), false)
assert.equal((await post('/api/admin/customers', neu({ email: 'off2@example.invalid', kundenart: '' }), admin)).status, 201)
assert.equal((await post('/api/admin/customers', neu({ email: 'off3@example.invalid', kundenart: 'privat' }), admin)).status, 201)
const bad = await post('/api/admin/customers', neu({ email: 'off4@example.invalid', kundenart: 'firma' }), admin)
assert.deepEqual([bad.status, bad.body.code], [400, 'kundenart_fehlt'])
})
+15
View File
@@ -448,6 +448,21 @@ test('Fassung 4 archiviert: HTML bis auf den PDF-Link bytegleich, PDF bytegleich
assert.ok(archived.includes('Version 4 · Stand 30.09.2026'))
})
// = `git show 7ae0ffd:public/abo-bedingungen.html | sha256sum` bzw. des PDFs (Fassung 5, Stand 30.09.2026)
const V5 = 'public/vertragsunterlagen/v5/abo-bedingungen.html'
const V5_SHA256 = '3b570eb2daed76d20fc5b8b9aea36a447f0481b21ac950452a82e454db45291e'
const V5_PDF_SHA256 = 'a8344e83c4d8f5ed2610048a5ea9ca6b8d2082b5648e706ea1ef01c10087e37b'
const V5_PDF_LINK = 'href="/WEBklar-Abo-Vertragsunterlagen.pdf"'
const V5_ARCHIVE_PDF_LINK = 'href="/vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf"'
test('Fassung 5 archiviert (vorbereitet für P10): HTML bis auf den PDF-Link bytegleich, PDF bytegleich', () => {
const archived = read(V5)
assert.equal(archived.split(V5_ARCHIVE_PDF_LINK).length, 2, 'genau ein Archiv-Link')
assert.ok(!archived.includes(V5_PDF_LINK))
assert.equal(createHash('sha256').update(archived.split(V5_ARCHIVE_PDF_LINK).join(V5_PDF_LINK)).digest('hex'), V5_SHA256)
assert.equal(sha256('public/vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf'), V5_PDF_SHA256)
assert.ok(archived.includes('Version 5 · Stand 30.09.2026'))
})
// = `git show 3a0b008:public/vertragsunterlagen/funktionen-leistungsbeschreibung.html | sha256sum` (News-Leistungsbeschreibung, Stand 30.09.2026).
// Bestellungen seit dem 30.09.2026 haben diesem Text zugestimmt; die geltende Datei (Stand 05.10.2026) ersetzt ihn nur für künftige Bestellungen.
const NEWS_LB = 'public/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
+76
View File
@@ -0,0 +1,76 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import { useTestEnv } from './support/testEnv.js'
// Generator der Rechtstexte: neuer Stil und Satz funktionen6 (Fassung 6, Entwurf). Ergänzt test/buildLegalHtml.test.js.
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { render, lauf, funktionen6Dokumente } = await import('../scripts/build-legal-html.mjs')
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const F = '2026-10-16'
const SLUGS = ['leistungsbeschreibung-news', 'leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'leistungsbeschreibung-webshop', 'avv-webshop', 'leistungsbeschreibung-unterkunft', 'avv-unterkunft', 'leistungsbeschreibung-zeiten', 'leistungsbeschreibung-website-analyse', 'avv-website-analyse']
test('funktionen6Dokumente: zehn Seiten unter public/neu/rechtliches, Quellen *-v6.md, Navigation im Vorschaupfad', () => {
const d = funktionen6Dokumente()
assert.deepEqual(d.map((x) => x.doc), ['news-lb', 'termine-lb', 'termine-avv', 'shop-lb', 'shop-avv', 'unterkunft-lb', 'unterkunft-avv', 'zeiten-lb', 'analyse-lb', 'analyse-avv'])
assert.deepEqual(d.map((x) => [x.md, x.html]), SLUGS.map((s) => [`docs/rechtliches/${s}-v6.md`, `public/neu/rechtliches/${s}-${F}.html`]))
for (const x of d) {
assert.deepEqual([x.meta.stil, x.meta.fassung], ['neu', F])
const hrefs = x.meta.nav.map((l) => l.href)
assert.ok(hrefs.includes('/neu/rechtliches/abo-bedingungen.html') && hrefs.includes(`/${x.html.slice('public/'.length)}`), x.doc)
assert.ok(hrefs.every((h) => !h.startsWith('/rechtliches/')), 'keine Verweise auf eingefrorene Seiten')
}
})
test('render mit stil neu: weiß und schwarz ohne Lila und Verlauf; ohne stil bleibt die alte Hülle', () => {
const meta = { fassung: F, titel: 'Prüfdokument', seitenkopf: 'Prüfdokument', nav: [{ href: '/dashboard.html', text: 'Portal' }] }
const md = `# Titel\n\n**Fassung ${F}** · Entwurf – Prüfung durch Kanzlei offen\n\n## 1. Geltung\n\n(1) Text.\n`
const neu = render(md, { ...meta, stil: 'neu' }), alt = render(md, meta)
for (const t of ['#1a1f36', '#ebeef1', '#f8f9fb', '#111', '-apple-system']) assert.ok(neu.includes(t), t)
assert.doesNotMatch(neu, /#6d1fd1|gradient|#f7f6f3/)
assert.ok(alt.includes('#6d1fd1') && alt.includes('#f7f6f3'))
assert.equal(neu.replace(/<style>[\s\S]*?<\/style>/, ''), alt.replace(/<style>[\s\S]*?<\/style>/, ''), 'nur die Hülle unterscheidet sich')
assert.throws(() => render(md, { ...meta, stil: 'dunkel' }), /stil/)
})
test('lauf funktionen6: schreibt in neuen Ordner, --check, --nur, falscher Aufruf', () => {
const dir = mkdtempSync(join(tmpdir(), 'legalv6-'))
try {
mkdirSync(join(dir, 'docs/rechtliches'), { recursive: true })
for (const s of SLUGS) writeFileSync(join(dir, `docs/rechtliches/${s}-v6.md`), `# T\n\n**Fassung ${F}** · Entwurf\n\nText.\n`)
const z = []; const ruf = (...a) => lauf(a, { root: dir, log: (x) => z.push(x), fehler: (x) => z.push(x) })
assert.equal(ruf('funktionen6', '--check'), 1)
assert.equal(ruf('funktionen6', '--nur=avv-webshop,news-lb'), 0)
assert.deepEqual(z.filter((x) => x.startsWith('->')), [`-> public/neu/rechtliches/leistungsbeschreibung-news-${F}.html`, `-> public/neu/rechtliches/avv-webshop-${F}.html`])
assert.equal(ruf('funktionen6'), 0); assert.equal(ruf('funktionen6', '--check'), 0)
writeFileSync(join(dir, 'docs/rechtliches/avv-webshop-v6.md'), `# T\n\n**Fassung ${F}** · Entwurf\n\nGeändert.\n`)
z.length = 0; assert.equal(ruf('funktionen6', '--check'), 1)
assert.deepEqual(z.filter((x) => x.startsWith('ABWEICHT')), [`ABWEICHT public/neu/rechtliches/avv-webshop-${F}.html`])
assert.equal(ruf('funktionen6', '--schreiben'), 2); assert.equal(ruf('shop', '--nur=x'), 2); assert.equal(ruf('funktionen6', '--nur=gibt-es-nicht'), 2)
} finally { rmSync(dir, { recursive: true, force: true }) }
})
test('build-legal-pdfs.sh: Modi abo6 und funktionen6, all und erste Usage-Zeile unverändert, nie parallel', () => {
const sh = readFileSync(join(ROOT, 'scripts/build-legal-pdfs.sh'), 'utf8')
assert.match(sh, /^abo6\)$/m); assert.match(sh, /^funktionen6\)$/m)
const abo6 = sh.slice(sh.indexOf('\nabo6)'), sh.indexOf('\nfunktionen6)'))
assert.ok(abo6.includes('public/neu/rechtliches/abo-bedingungen.html') && abo6.includes('public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf') && abo6.includes('.sha256'))
for (const s of SLUGS) assert.ok(sh.includes(s), s)
assert.ok(sh.includes('FUNKTIONEN_FASSUNG_V6') && sh.includes('zuerst node scripts/build-legal-html.mjs funktionen6'))
const all = sh.slice(sh.indexOf('\nall)'), sh.indexOf('\n*)'))
assert.ok(!all.includes('neu/rechtliches') && all.includes(' pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf\n'), 'all druckt die Entwürfe nicht')
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|shop|unterkunft|datenschutz]') && sh.includes('abo6|funktionen6'))
assert.match(sh, /^ {2}timeout 120 "\$CHROME" /m)
assert.ok(!/ &$|^\s*wait\b|xargs -P/m.test(sh))
assert.equal(spawnSync('bash', ['-n', join(ROOT, 'scripts/build-legal-pdfs.sh')], { encoding: 'utf8' }).status, 0)
})
test('PDFs der Fassung 6: erst nach der Freigabe vorhanden; wenn vorhanden, PDF, Prüfsumme der Seite daneben', { skip: !existsSync(join(ROOT, 'public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf')) && 'PDFs entstehen erst nach der Freigabe (Task 11, Ablauf)' }, () => {
for (const p of ['abo-bedingungen.html', ...SLUGS.map((s) => `${s}-2026-10-16.html`)]) {
const pdf = join(ROOT, 'public/neu/rechtliches', p === 'abo-bedingungen.html' ? 'WEBklar-Abo-Vertragsunterlagen.pdf' : p.replace('.html', '.pdf'))
assert.equal(readFileSync(pdf).subarray(0, 5).toString(), '%PDF-', pdf)
assert.equal(readFileSync(`${pdf}.sha256`, 'utf8').trim(), createHash('sha256').update(readFileSync(join(ROOT, 'public/neu/rechtliches', p))).digest('hex'), p)
}
})
+3 -3
View File
@@ -6,9 +6,9 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url)
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9); K33–K37 und T23 Buchungskalender (K36 und K37 Neue Anfrage und Neue Buchung an den Vermieter, T23 Team-Hinweis; zuvor K31, K32, T22, die den Aufträgen gehören); K31, K32 und T22 Auftragsbestätigung (Aufträge und Rechnungen, Strang L5; K31 und K32 in Sie-Form, T22 mit [Aufträge])
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9); K33–K37 und T23 Buchungskalender (K36 und K37 Neue Anfrage und Neue Buchung an den Vermieter, T23 Team-Hinweis; zuvor K31, K32, T22, die den Aufträgen gehören); K31, K32 und T22 Auftragsbestätigung (Aufträge und Rechnungen, Strang L5; K31 und K32 in Sie-Form, T22 mit [Aufträge]); K42, K43 Zusage-Mails ohne Preis (Kundenansicht P6: Webshop- und Buchungskalender-Anfrage, Ersatz für K24 und K34 im Abo-Modell); K38–K41 und T24 Funktionen im Abo-Modell (Kundenansicht P6: K38 wird eingerichtet, K39 beendet, K40 und K41 Öffnungszeiten live Abo-Ende und abgeschaltet ohne Preis, T24 Team-Hinweis; Du-Form, Test in aboFunktionenVorlagen.test.js)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
const EXPECTED = [...ids('G', 22), ...ids('K', 37), ...ids('T', 23)]
const EXPECTED = [...ids('G', 22), ...ids('K', 43), ...ids('T', 24)]
// GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten)
const GUEST = ids('G', 8)
const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15']
@@ -715,7 +715,7 @@ test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => {
})
// Etappe 4: das Admin-Panel ist abgeschaltet, Team-Mails fuehren ins Ticketsystem
const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21', 'T22']
const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21', 'T22', 'T24']
test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => {
for (const id of EXPECTED) {
+39
View File
@@ -1153,3 +1153,42 @@ test('Abgleich mit veraltetem Ereignis nach dem Webhook: kein zweites T12', asyn
assert.equal((await listContractEvents('c1'))[0].$id, stale.$id)
assert.equal((await listContractEvents('c1'))[0].doneBy, 'stripe')
})
// Kundenansicht P6 (Task 12): der Wechsel merkt sich die gewünschte Funktion (details.funktion) und GET /api/abo nennt sie (wechselFunktion)
const NEU_MODUS = { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] }
after(() => {
config.kundenansichtNeu.mode = NEU_MODUS.mode
config.kundenansichtNeu.pilotEmails = NEU_MODUS.pilotEmails
})
const pilot = (an) => { config.kundenansichtNeu.mode = an ? 'pilot' : 'off'; config.kundenansichtNeu.pilotEmails = ['k@example.invalid'] }
const F = { key: 'termine', projectId: 'wf_p1' }
aw.seed('websiteProjects', { $id: 'wf_p1', customerId: 'c1', projectName: 'Bäckerei' }); aw.seed('websiteProjects', { $id: 'wf_fremd', customerId: 'c2', projectName: 'Fremd' })
const bestelle = async (extra = {}) => {
await resetHosting(); st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000)); st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
return post('/api/abo/switch', { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100, ...extra }, { cookie: customerCookie() })
}
const wechsel = async () => (await listContractEvents('c1')).find((e) => e.type === 'wechsel')
test('Wechsel mit Funktion: nur in der neuen Ansicht gespeichert; Ungültiges wird verworfen, der Wechsel läuft trotzdem', async () => {
pilot(true); assert.equal((await bestelle({ funktion: F })).status, 200); assert.deepEqual((await wechsel()).details.funktion, F); assert.ok(JSON.stringify((await wechsel()).details).length <= 2000)
for (const funktion of [{ key: 'analyse', projectId: 'wf_p1' }, { key: 'foo', projectId: 'wf_p1' }, { key: 'termine', projectId: 'wf_fremd' }, { key: 'termine' }, 'termine', [], null]) {
assert.equal((await bestelle({ funktion })).status, 200, JSON.stringify(funktion)); assert.equal(Object.hasOwn((await wechsel()).details, 'funktion'), false, JSON.stringify(funktion))
}
pilot(false); assert.equal((await bestelle({ funktion: F })).status, 200); assert.equal(Object.hasOwn((await wechsel()).details, 'funktion'), false)
})
test('GET /api/abo: wechselFunktion folgt dem jüngsten erledigten Wechsel, 14 Tage, Funktion off; alte Ansicht ohne den Schlüssel', async () => {
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
const ts = (tage) => new Date(Date.now() - tage * 86400000).toISOString()
const lege = async ({ status = 'erledigt', paidVor = 2, doneVor = null, funktion = F } = {}) => {
const ev = await recordContractEvent({ customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', consent, status, details: { from: 'hosting', switchedAt: ts(paidVor), ...(status === 'erledigt' && doneVor === null ? { paidAt: ts(paidVor) } : {}), ...(funktion ? { funktion } : {}) } })
if (doneVor !== null) await updateContractEvent(ev.$id, { status: 'erledigt', doneAt: ts(doneVor) })
}
const lies = async () => (await get('/api/abo', { cookie: customerCookie() })).subscription
const neu = async (setup) => { await resetHosting({ patch: { productKey: 'wartung' } }); await lege(setup); return (await lies()).wechselFunktion }
pilot(true)
assert.deepEqual(await neu({}), F); assert.deepEqual(await neu({ paidVor: 20, doneVor: 13 }), F) // sofort bezahlt (paidAt) bzw. später erledigt (doneAt)
for (const setup of [{ status: 'offen' }, { status: 'gescheitert' }, { status: 'widerrufen' }, { paidVor: 20, doneVor: 15 }, { funktion: null }]) assert.equal(await neu(setup), null, JSON.stringify(setup))
await neu({}); await lege({ funktion: null }); assert.equal((await lies()).wechselFunktion, null) // der jüngere Wechsel ohne Funktion gewinnt
await neu({}); aw.seed('portalFeatures', { $id: 'f_wf_p1_termine', customerId: 'c1', projectId: 'wf_p1', featureKey: 'termine', state: 'setup' }); assert.equal((await lies()).wechselFunktion, null)
await updateDocument('portalFeatures', 'f_wf_p1_termine', { state: 'off' }); assert.deepEqual((await lies()).wechselFunktion, F)
pilot(false); assert.equal(Object.hasOwn(await lies(), 'wechselFunktion'), false)
})
+45
View File
@@ -14,6 +14,9 @@ const {
productView,
contractAttachmentFor,
regularStorageLine,
FASSUNGEN,
aktuelleAboFassung,
isTermsV4OrNewer,
} = await import('../server/services/products.js')
const line = (price, amount, proration = false) => ({
@@ -136,6 +139,48 @@ test('Vertragsunterlagen je Version', () => {
assert.equal(termsDocFor('2026-09-25-v2').version, '2026-09-25-v2', 'aeltere Fassung bleibt benannt')
})
test('Fassungstabelle: Reihenfolge, Muster nur am Ende, v6 im Vorschaupfad', () => {
assert.deepEqual(FASSUNGEN.map((f) => f.muster?.source ?? 'sonst'), ['-v6$', '-v5$', '-v4$', 'sonst'])
assert.deepEqual(termsDocFor('2026-10-16-v6'), {
version: '2026-10-16-v6', url: '/neu/rechtliches/abo-bedingungen.html',
pdfFile: 'neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph: '§ 11',
})
for (const v of ['2026-10-16-v60', '2026-10-16-v6x', 'v6-2026-10-16', '2026-10-16-v66']) {
assert.equal(termsDocFor(v).url, '/vertragsunterlagen/v3/abo-bedingungen.html', v)
}
const a = contractAttachmentFor('2026-10-16-v6')
assert.equal(a.filename, 'WEBklar-Abo-Vertragsunterlagen.pdf')
assert.ok(path.isAbsolute(a.path) && a.path.endsWith(path.join('public', 'neu', 'rechtliches', 'WEBklar-Abo-Vertragsunterlagen.pdf')))
})
test('Fassung 5 bleibt bis P10 an den heutigen Adressen, das Archiv ist vorbereitet', () => {
const v5 = FASSUNGEN.find((f) => f.muster?.source === '-v5$')
assert.equal(termsDocFor('2026-09-30-v5').url, '/abo-bedingungen.html')
assert.deepEqual(Object.keys(termsDocFor('2026-09-30-v5')).sort(), ['endParagraph', 'pdfFile', 'url', 'version'])
assert.deepEqual(v5.archiv, { url: '/vertragsunterlagen/v5/abo-bedingungen.html', pdfFile: 'vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf' })
})
test('isTermsV4OrNewer: v4, v5, v6 ja; v3, fehlende und fremde Endungen nein', () => {
for (const v of ['2026-09-30-v4', '2026-09-30-v5', '2026-10-16-v6']) assert.equal(isTermsV4OrNewer(v), true, v)
for (const v of ['2026-09-25-v3', '2026-09-25', null, undefined, '', '2026-10-16-v60', 7]) assert.equal(isTermsV4OrNewer(v), false, String(v))
})
test('aktuelleAboFassung: v6 nur mit neuer Ansicht', () => {
// appwriteUserId gehört zur gültigen Kunden-Sitzung (N1, schalter.js istKundenSitzung)
const k = (email, extra = {}) => ({ session: { customerId: 'c1', appwriteUserId: 'u1', email, ...extra } })
const vorher = { ...config.kundenansichtNeu }
try {
Object.assign(config.kundenansichtNeu, { mode: 'off', pilotEmails: ['*'] })
assert.equal(aktuelleAboFassung(k('a@example.invalid')), '2026-09-30-v5', 'off schlägt die Liste')
Object.assign(config.kundenansichtNeu, { mode: 'pilot', pilotEmails: [] })
assert.equal(aktuelleAboFassung(k('a@example.invalid')), '2026-09-30-v5', 'leere Liste lässt niemanden durch')
config.kundenansichtNeu.pilotEmails = ['a@example.invalid']
assert.equal(aktuelleAboFassung(k(' A@Example.INVALID ')), '2026-10-16-v6', 'Großschreibung und Leerraum')
assert.equal(aktuelleAboFassung(k('b@example.invalid')), '2026-09-30-v5')
assert.equal(aktuelleAboFassung(k('b@example.invalid', { viewAs: true, viewId: 'v1' })), '2026-10-16-v6', 'Mitarbeiter-Kundenansicht')
Object.assign(config.kundenansichtNeu, { mode: 'on', pilotEmails: [] })
assert.equal(aktuelleAboFassung(k('b@example.invalid')), '2026-10-16-v6')
} finally { Object.assign(config.kundenansichtNeu, vorher) }
assert.deepEqual([config.abo.termsVersion, config.abo.termsVersionNeu], ['2026-09-30-v5', '2026-10-16-v6'])
})
test('Produktansicht enthält Leistungen ohne 1-Postfach-Regel bei Wartung', () => {
const w = productView('wartung').features.flat().join(' ')
assert.ok(w.includes('beliebig viele Postfächer') && !w.includes('1 E-Mail-Postfach'))
+61
View File
@@ -1464,3 +1464,64 @@ test('legal-form.js: Auswahl statt festem Vertrag, packages nur beim Zusatzspeic
assert.doesNotMatch(src, /\['Vertrag', 'WEBklar Wartungs-Abo'\]/)
assert.match(src, /packages/)
})
// Kundenansicht P6 (Task 5): Funktionen des Abo-Modells enden mit dem Abo; K02 und K03 sagen es mit dem Block funktionen_abo
test('istAboModell: nur mit beiden Markern aus abo und projekt', async () => {
const { istAboModell } = await import('../server/services/aboModell.js')
for (const [doc, soll] of [[{ unlockedBy: 'abo', einrichtungAbrechnung: 'abo' }, true], [{ unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' }, true], [{ unlockedBy: 'abo', einrichtungAbrechnung: 'projekt' }, true],
[{ unlockedBy: 'abo' }, false], [{ unlockedBy: 'kauf', einrichtungAbrechnung: 'abo' }, false], [{ unlockedBy: 'team', einrichtungAbrechnung: 'abo' }, false], [{ einrichtungAbrechnung: 'abo' }, false], [{ unlockedBy: 'abo', einrichtungAbrechnung: '' }, false], [null, false], [undefined, false]]) assert.equal(istAboModell(doc), soll, JSON.stringify(doc))
})
const MODELL_SATZ = 'enden mit dem Abo. Ihre Eingaben bleiben danach noch 30 Tage gespeichert'
const MODELL_DOC = { featureKey: 'news', state: 'setup', unlockedBy: 'abo', einrichtungAbrechnung: 'abo' }
for (const [name, doc, erwartet] of [['Abo-Modell-Dokument (news, setup)', MODELL_DOC, true], ['Abo-Modell, Projektkosten', { ...MODELL_DOC, unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' }, true], ['News ohne Marker (Altbestand)', { featureKey: 'news', state: 'active' }, false], ['Abo-Modell-Dokument im Zustand off', { ...MODELL_DOC, state: 'off' }, false]]) {
test(`K03 und K02, Wartungs-Abo: ${name} => ${erwartet ? 'neuer Hinweis, nicht der alte' : 'kein neuer Hinweis'}`, async () => {
for (const mail of [await mitFunktion(doc, () => kuendigeMitK03(WARTUNG)), await mitFunktion(doc, () => kuendigeMitK02(WARTUNG))]) {
assert.equal(mail.text.includes(MODELL_SATZ), erwartet, mail.text); assert.equal(mail.html.includes('Terminplaner, Webshop'), erwartet)
assert.equal(mail.text.includes(FUNKTIONEN_SATZ), false)
}
})
}
test('K03: Abo-Modell-Dokument und alte Analyse zusammen: nur der neue Satz; Hosting-Abo: kein Hinweis und kein Lesen', async () => {
await mitFunktion(MODELL_DOC, async () => {
aw.seed('portalFeatures', { $id: 'fk_zweit', customerId: 'c1', projectId: 'fk_p2', featureKey: 'analyse', state: 'active', enabled: true })
const mail = await kuendigeMitK03(WARTUNG); assert.ok(mail.text.includes(MODELL_SATZ)); assert.equal(mail.text.includes(FUNKTIONEN_SATZ), false)
await deleteDocument(config.collections.portalFeatures, 'fk_zweit').catch(() => {})
aw.resetListCalls(); assert.equal((await kuendigeMitK03()).text.includes(MODELL_SATZ), false); assert.equal(aw.listCalls('portalFeatures').length, 0)
})
})
// Kundenansicht P6 (Task 13): der Widerruf des Wechsels beendet die Funktionen des Abo-Modells, die nur im Wartungs-Abo stecken; die Analyse bleibt
const NEU_MODUS = { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] }
after(() => {
config.kundenansichtNeu.mode = NEU_MODUS.mode
config.kundenansichtNeu.pilotEmails = NEU_MODUS.pilotEmails
})
const pilot = (an) => { config.kundenansichtNeu.mode = an ? 'pilot' : 'off'; config.kundenansichtNeu.pilotEmails = ['k@example.invalid'] }
const wm = (key, over = {}) => ({ $id: `wm_${key}`, customerId: 'c1', projectId: 'wf_pt', featureKey: key, siteKey: `wmkey${key}`.slice(0, 16).padEnd(16, '2'), state: 'active', enabled: true, activatedAt: daysAgo(10), unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo', ...over })
const wdoc = (id) => aw.docs('portalFeatures').get(id)
const k39 = () => mails.sent.filter((m) => m.subject.startsWith('Deine Funktion wurde beendet'))
const mitModell = async (keys, fn) => { for (const k of keys) aw.seed('portalFeatures', wm(k)); try { return await fn() } finally { for (const k of keys) await deleteDocument(config.collections.portalFeatures, `wm_${k}`).catch(() => {}) } }
test('Widerruf Wechsel, neue Ansicht: alle Wartungs-Funktionen des Abo-Modells enden (abo_ende, K39 Hosting), Bestand und Analyse bleiben', async () => {
pilot(true); await wartungSpiegel(); await wechselIn(3)
aw.seed('portalFeatures', wm('shop', { $id: 'wm_shop_p', projectId: 'wf_p2', unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' })); aw.seed('portalFeatures', wm('news', { $id: 'wm_kauf', projectId: 'wf_p3', unlockedBy: 'kauf', unlockedByPurchase: true, einrichtungAbrechnung: '' }))
await mitModell(['news', 'termine', 'shop', 'unterkunft', 'zeiten'], () => withActiveAnalyse(async (stops) => {
const res = await withdraw({ order: 'wechsel' }); assert.equal(res.status, 200, JSON.stringify(res.body))
for (const id of ['wm_news', 'wm_termine', 'wm_shop', 'wm_unterkunft', 'wm_zeiten', 'wm_shop_p']) assert.deepEqual([wdoc(id).state, wdoc(id).disabledReason, wdoc(id).adminNote], ['disabled', 'abo_ende', 'Abgeschaltet: Wechsel widerrufen'], id)
assert.equal(wdoc('wm_kauf').state, 'active'); assert.deepEqual(stops, []); assert.equal(wdoc('wf_feat').state, 'active'); assert.equal(bySubject('Deine Website-Analyse wurde beendet'), null)
assert.equal(k39().length, 5); assert.ok(k39().every((m) => /ein Hosting-Abo reicht dafür nicht/.test(m.text))); assert.ok(k04() && t09()) // zeiten: K41 kommt vom Zeiten-Abgleich
}))
for (const id of ['wm_shop_p', 'wm_kauf']) await deleteDocument(config.collections.portalFeatures, id).catch(() => {})
})
test('Widerruf Wechsel, alte Ansicht: Analyse endet wie bisher', async () => {
pilot(false); await wartungSpiegel(); await wechselIn(3)
await withActiveAnalyse(async (stops) => { assert.equal((await withdraw({ order: 'wechsel' })).status, 200); assert.deepEqual(stops, ['wfsitekey0123456789']); assert.equal(wdoc('wf_feat').state, 'off') })
})
test('Widerruf Wechsel: scheitert eine Abschaltung, bleibt der Widerruf 200 und die übrigen enden; der Tagesabgleich holt die letzte nach', async (t) => {
const { gleicheAboFunktionenAb } = await import('../server/services/aboFunktionenAbgleich.js')
t.mock.method(console, 'error', () => {}); pilot(true); await wartungSpiegel(); await wechselIn(3)
await mitModell(['news', 'termine', 'shop'], async () => {
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
assert.equal((await withdraw({ order: 'wechsel' })).status, 200); assert.equal(['wm_news', 'wm_termine', 'wm_shop'].filter((id) => wdoc(id).state === 'active').length, 1); assert.equal((await events('wechsel'))[0].status, 'widerrufen')
await gleicheAboFunktionenAb(new Date()); assert.equal(['wm_news', 'wm_termine', 'wm_shop'].filter((id) => wdoc(id).state === 'active').length, 0); assert.equal(k39().length, 3)
})
})
+93
View File
@@ -0,0 +1,93 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
// Hochladen der Fassung 6 nach WOMS "Rechtliches" (scripts/rechtliches-hochladen.mjs): die Tests rufen main nur mit Nachbau-deps auf,
// nie mit dem echten Schlüssel oder dem Netz.
const { FUNKTIONEN_UNTERLAGEN_V6, FUNKTIONEN_FASSUNG_V6 } = await import('../server/services/products.js')
const m = await import('../scripts/rechtliches-hochladen.mjs') // der Import selbst darf weder Schlüssel lesen noch das Netz nutzen
test('parseArgs: ein Dokument je Aufruf, Hochladen nur mit --freigabe=Datum, --probe und --pruefen lesen nur, --karten-anlegen legt nur Karten an', () => {
assert.deepEqual(m.parseArgs(['--probe']), { modus: 'probe', documentKey: null, freigabe: null })
assert.deepEqual(m.parseArgs(['--karten-anlegen']), { modus: 'karten', documentKey: null, freigabe: null })
assert.deepEqual(m.parseArgs(['--pruefen']), { modus: 'pruefen', documentKey: null, freigabe: null })
assert.deepEqual(m.parseArgs(['avv-webshop', '--freigabe=2026-10-20']), { modus: 'hochladen', documentKey: 'avv-webshop', freigabe: '2026-10-20' })
for (const bad of [[], ['avv-webshop'], ['--freigabe=2026-10-20'], ['avv-webshop', 'avv-terminplaner', '--freigabe=2026-10-20'], ['avv-webshop', '--freigabe=20.10.2026'], ['avv-webshop', '--freigabe=2026-13-40'], ['datenschutz', '--freigabe=2026-10-20'], ['--probe', 'avv-webshop'], ['--probe', '--freigabe=2026-10-20'], ['--karten-anlegen', 'avv-webshop'], ['--karten-anlegen', '--freigabe=2026-10-20'], ['--pruefen', '--probe'], ['--pruefen', 'avv-webshop']]) {
assert.throws(() => m.parseArgs(bad), /Aufruf|Freigabe|Dokument/, JSON.stringify(bad))
}
assert.equal(typeof m.main, 'function')
})
test('Tabelle: elf Dokumente, Dateien im Vorschaupfad, passend zu FUNKTIONEN_UNTERLAGEN_V6', () => {
assert.deepEqual(m.DOKUMENTE_V6.map((d) => d.documentKey), ['abo-bedingungen', 'leistungsbeschreibung-news', 'leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'leistungsbeschreibung-webshop', 'avv-webshop', 'leistungsbeschreibung-unterkunft', 'avv-unterkunft', 'leistungsbeschreibung-zeiten', 'leistungsbeschreibung-website-analyse', 'avv-website-analyse'])
assert.deepEqual(m.dokumentFuer('abo-bedingungen').dateien, ['public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf', 'public/neu/rechtliches/abo-bedingungen.html'])
const unterlagen = Object.values(FUNKTIONEN_UNTERLAGEN_V6).flatMap((u) => [u.lb, u.avv]).filter(Boolean)
assert.deepEqual(m.DOKUMENTE_V6.slice(1).map((d) => d.dateien), unterlagen.map((u) => [`public/${u.pdfFile}`, `public${u.url}`]))
assert.deepEqual(m.DOKUMENTE_V6.map((d) => d.quelle), m.DOKUMENTE_V6.map((d) => d.dateien[1]), 'quelle = HTML-Pfad, der in source steht')
assert.throws(() => m.dokumentFuer('datenschutz'), /Unbekanntes Dokument/)
})
test('planVersion: neue Fassung als vN, bisherige gültige wird abgelöst, doppelter Upload wird erkannt', () => {
const alle = [{ $id: 'a', version: 'v1', validFrom: '2026-09-30', status: 'current', source: 'x' }, { $id: 'b', version: 'v0', validFrom: '2026-09-25', status: 'superseded', source: 'y' }]
assert.deepEqual(m.planVersion({ alle, source: 's', validFrom: '2026-10-20' }), { schon: false, nummer: 'v3', aktuellId: 'a', validToAltes: '2026-10-19' })
assert.equal(m.planVersion({ alle, source: 'x', validFrom: '2026-10-20' }).schon, true)
assert.deepEqual(m.planVersion({ alle: [], source: 's', validFrom: '2026-10-20' }), { schon: false, nummer: 'v1', aktuellId: null, validToAltes: null })
assert.equal(m.planVersion({ alle, source: 's', validFrom: '2026-09-30' }).validToAltes, '2026-09-30', 'gleicher Tag: validTo = validFrom wie in der Vorlage')
assert.equal(m.tagVorher('2026-03-01'), '2026-02-28')
})
// Nachbau des WOMS-Backends für main(): Karten als Dokumente { $id, key, sort }, Versionen und Dateien leer; zählt alle Aufrufe
function backend(karten) {
const aufrufe = [], log = [], antwort = (obj) => ({ ok: true, status: 200, text: async () => JSON.stringify(obj) })
const fetch = async (url, opt = {}) => {
const methode = opt.method ?? 'GET', pfad = new URL(url).pathname.replace(/^\/v1/, ''); aufrufe.push(`${methode} ${pfad}`)
if (methode === 'GET' && pfad.endsWith('/collections/legalDocuments/documents')) return antwort({ total: karten.length, documents: karten })
if (methode === 'GET' && pfad.endsWith('/collections/legalVersions/documents')) return antwort({ total: 0, documents: [] })
if (methode === 'GET' && pfad.startsWith('/storage/buckets/legal-files/files')) return antwort({ total: 0, files: [] })
if (methode === 'POST' && pfad.endsWith('/collections/legalDocuments/documents')) { const b = JSON.parse(opt.body); karten.push({ $id: b.documentId, ...b.data }); return antwort({ $id: b.documentId }) }
throw new Error(`unerwartet: ${methode} ${pfad}`)
}
return { karten, aufrufe, log, deps: { fetch, schluessel: () => 'test-schluessel', log: (z) => log.push(z) } }
}
const NUR_LESEN = (b) => b.aufrufe.every((a) => a.startsWith('GET '))
const FEHLEN = ['leistungsbeschreibung-news', 'leistungsbeschreibung-website-analyse', 'avv-website-analyse'] // „leistungsbeschreibung-website-analyse“ hat 37 Zeichen, eine Dokument-ID darf höchstens 36 haben
const kartenDoc = (key, i) => ({ $id: key.slice(0, 36), key, sort: i + 1 })
const VORHANDEN = m.DOKUMENTE_V6.map((d) => d.documentKey).filter((k) => !FEHLEN.includes(k)) // die Karten laut WOMS scripts/setup-legal.mjs
test('kartenPlan: genau die drei fehlenden Karten in Tabellenreihenfolge, mit zulässiger Dokument-ID', () => {
const plan = m.kartenPlan({ vorhandeneKeys: VORHANDEN, hoechsteSort: 14 })
assert.deepEqual(plan.map((k) => [k.daten.key, k.daten.sort]), [[FEHLEN[0], 15], [FEHLEN[1], 16], [FEHLEN[2], 17]])
assert.deepEqual(plan.map((k) => k.daten.title), ['Leistungsbeschreibung News-Pinnwand', 'Leistungsbeschreibung Website-Analyse', 'AVV Website-Analyse'])
assert.ok(plan.every((k) => k.daten.liveUrl.startsWith('https://project.webklar.com/neu/rechtliches/') && k.daten.liveUrl.endsWith(`-${FUNKTIONEN_FASSUNG_V6}.html`)))
assert.ok(plan.every((k) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(k.id)), 'Dokument-ID höchstens 36 Zeichen')
assert.equal(plan[0].id, FEHLEN[0], 'kurze Schlüssel bleiben die ID, wie in setup-legal.mjs')
assert.notEqual(plan[1].id, FEHLEN[1]); assert.equal(m.kartenId(FEHLEN[1]), plan[1].id, 'lange Schlüssel: abgeleitete ID, immer dieselbe')
assert.deepEqual(m.kartenPlan({ vorhandeneKeys: m.DOKUMENTE_V6.map((d) => d.documentKey), hoechsteSort: 20 }), [])
})
test('--probe: eine fehlende Karte ist ein Stopp (Exit 1), mit allen Karten Exit 0; schreibt nie', async () => {
const b = backend(VORHANDEN.map(kartenDoc))
assert.equal(await m.main(['--probe'], b.deps), 1)
for (const k of FEHLEN) assert.ok(b.log.includes(`STOPP: Karte fehlt: ${k}`), k)
assert.ok(NUR_LESEN(b), b.aufrufe.join(', '))
assert.equal(await m.main(['--probe'], backend(m.DOKUMENTE_V6.map((d, i) => kartenDoc(d.documentKey, i))).deps), 0)
})
test('--karten-anlegen: legt nur die drei fehlenden Karten an, ein zweiter Lauf tut nichts, danach ist --probe grün', async () => {
const b = backend(VORHANDEN.map(kartenDoc))
assert.equal(await m.main(['--karten-anlegen'], b.deps), 0)
assert.deepEqual(b.aufrufe.filter((a) => !a.startsWith('GET ')), FEHLEN.map(() => 'POST /databases/woms-database/collections/legalDocuments/documents'))
assert.deepEqual(b.karten.slice(VORHANDEN.length).map((k) => k.key), FEHLEN)
b.aufrufe.length = 0
assert.equal(await m.main(['--karten-anlegen'], b.deps), 0); assert.ok(NUR_LESEN(b))
assert.equal(await m.main(['--probe'], b.deps), 0)
})
test('Hochladen ohne Karte bricht ab, bevor etwas geschrieben wird', async () => {
const b = backend(VORHANDEN.map(kartenDoc))
assert.equal(await m.main([FEHLEN[0], '--freigabe=2026-10-20'], b.deps), 1)
assert.ok(b.log.some((z) => z.includes(`Karte fehlt: ${FEHLEN[0]}`)) && NUR_LESEN(b))
})
test('pruefeHochgeladen und --pruefen: alle elf Dokumente brauchen eine aktuelle Version mit passender Quelle', async () => {
const ok = (d) => [{ $id: 'v', version: 'v2', status: 'current', source: `kundenbereich@abc1234:${d.quelle}` }]
const alle = Object.fromEntries(m.DOKUMENTE_V6.map((d) => [d.documentKey, ok(d)]))
assert.deepEqual(m.pruefeHochgeladen(alle), { fehlt: [] })
assert.deepEqual(m.pruefeHochgeladen({ ...alle, 'avv-website-analyse': [] }).fehlt, ['avv-website-analyse'])
assert.deepEqual(m.pruefeHochgeladen({ ...alle, 'abo-bedingungen': [{ ...alle['abo-bedingungen'][0], source: 'kundenbereich@7ae0ffd:public/abo-bedingungen.html' }] }).fehlt, ['abo-bedingungen'], 'Fassung 5 zählt nicht')
assert.deepEqual(m.pruefeHochgeladen({ ...alle, 'leistungsbeschreibung-news': [{ ...alle['leistungsbeschreibung-news'][0], status: 'superseded' }] }).fehlt, ['leistungsbeschreibung-news'])
assert.equal(m.pruefeHochgeladen({}).fehlt.length, 11)
const b = backend([]) // Versionen leer: alle elf fehlen
assert.equal(await m.main(['--pruefen'], b.deps), 1)
assert.equal(b.log.filter((z) => z.startsWith('FEHLT ')).length, 11); assert.ok(NUR_LESEN(b))
})
+14
View File
@@ -252,3 +252,17 @@ test('nach Ablehnung kann der Kunde neu anfragen, die alte Entscheidung ist gel
assert.equal(doc.anfrageBegruendung, '')
assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 200)
})
const ansichtAlt = { ...config.kundenansichtNeu }; after(() => Object.assign(config.kundenansichtNeu, ansichtAlt))
const neueAnsicht = (an) => { config.kundenansichtNeu.mode = an ? 'pilot' : 'off'; config.kundenansichtNeu.pilotEmails = [customers.c1.email] }
test('Abo-Modell: Zusage als K42 ohne Preis und ohne „bestellen“, Absage mit Begründung; alte Ansicht bekommt weiter K24 mit 299 €', async () => {
for (const an of [true, false]) {
neueAnsicht(an); await leereAlles(); await stelle(); mails.sent.length = 0
assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 200)
const m = k24()[0]; assert.equal(m.subject, 'Deine Webshop-Anfrage für Töpferei'); assert.match(m.html, /dashboard\.html#webshop\?projekt=pt1"/)
if (an) { assert.doesNotMatch(m.text + m.html, /€|UStG|bestell/i); assert.match(m.text, /jetzt im Kundenportal einschalten/) } else { assert.match(m.text, /299 €/); assert.match(m.text, /bestellen/) }
}
neueAnsicht(true); await leereAlles(); await stelle(); mails.sent.length = 0
assert.equal((await entscheide({ entscheidung: 'ablehnen', begruendung: 'Passt nicht.' })).status, 200)
assert.match(k24()[0].text, /leider[\s\S]*Passt nicht\./); assert.doesNotMatch(k24()[0].text, /299 €/)
})
+6 -2
View File
@@ -5,6 +5,7 @@ import { UNTERKUNFT_COLLECTIONS } from '../../server/services/unterkunft/schema.
import { ZEITEN_COLLECTIONS, ZEITEN_FEATURE_ATTRIBUTES } from '../../server/services/zeiten/schema.js'
import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../../server/services/auftraege/schema.js'
import { FEATURE_CONSENT_ATTRIBUTES, PORTAL_FEATURE_ATTRIBUTES } from '../../scripts/feature-setup.mjs'
import { KUNDENANSICHT_ATTRIBUTE } from '../../scripts/kundenansicht-setup.mjs'
// Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],)
const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
@@ -20,6 +21,8 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
* portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES) und die
* Zeiten-Felder aus server/services/zeiten/schema.js (ZEITEN_FEATURE_ATTRIBUTES, angelegt von scripts/zeiten-setup.mjs);
* featureConsents (Zustimmungsnachweis) steht ganz dort (FEATURE_CONSENT_ATTRIBUTES);
* scripts/kundenansicht-setup.mjs (KUNDENANSICHT_ATTRIBUTE) ergaenzt featureConsents um kundenart und portalFeatures um
* unlockedBy und einrichtungAbrechnung;
* die Aufträge-Collections kommen aus server/services/auftraege/schema.js (scripts/auftraege-setup.mjs), und ihre
* Zusatzfelder bestehender Collections (AUFTRAEGE_ZUSATZ_ATTRIBUTE, z. B. financeSettings.steuernummer) werden angehängt.
*/
@@ -29,7 +32,7 @@ export function schemaKeys(collection) {
if (Object.hasOwn(SHOP_COLLECTIONS, collection)) return SHOP_COLLECTIONS[collection].attributes.map((a) => a.key)
if (Object.hasOwn(UNTERKUNFT_COLLECTIONS, collection)) return UNTERKUNFT_COLLECTIONS[collection].attributes.map((a) => a.key)
if (Object.hasOwn(ZEITEN_COLLECTIONS, collection)) return ZEITEN_COLLECTIONS[collection].attributes.map((a) => a.key)
if (collection === 'featureConsents') return FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key)
if (collection === 'featureConsents') return [...FEATURE_CONSENT_ATTRIBUTES, ...KUNDENANSICHT_ATTRIBUTE.featureConsents].map((a) => a.key)
for (const script of SETUP_SCRIPTS) {
const src = readFileSync(new URL(`../../scripts/${script}`, import.meta.url), 'utf8')
const start = src.indexOf(`'${collection}',`)
@@ -37,7 +40,8 @@ export function schemaKeys(collection) {
const block = src.slice(start, src.indexOf('\n ],', start))
const keys = [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1])
if (collection === 'portalFeatures') {
return [...keys, ...PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key), ...ZEITEN_FEATURE_ATTRIBUTES.map((a) => a.key)]
const zusatz = [...PORTAL_FEATURE_ATTRIBUTES, ...ZEITEN_FEATURE_ATTRIBUTES, ...KUNDENANSICHT_ATTRIBUTE.portalFeatures]
return [...new Set([...keys, ...zusatz.map((a) => a.key)])]
}
return Object.hasOwn(AUFTRAEGE_ZUSATZ_ATTRIBUTE, collection)
? [...keys, ...AUFTRAEGE_ZUSATZ_ATTRIBUTE[collection].map((a) => a.key)]
+36
View File
@@ -0,0 +1,36 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Team-Endpunkt Stundenkonto fuers Ticketsystem: Produkt, Guthaben, Vorschuss, Mehraufwand
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { startTestApp, rawGet } = await import('./support/testApp.js')
const { seedFixtures } = await import('./support/fixtures.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
const app = await startTestApp({ '/api/team': teamRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
const auth = { headers: { authorization: 'Bearer jwt-staff' } }
test('Wartung: Guthaben, Vorschuss und Mehraufwand aus dem Stundenkonto', async () => {
aw.seed('subscriptions', { $id: 'sub_t', customerId: 'c3', status: 'active', productKey: 'wartung', storageUnits: 0, startedAt: '2026-09-01T10:00:00.000Z' })
aw.seed('hourCredits', { $id: 'h1', customerId: 'c3', minutes: 60, grantedAt: '2026-09-01T00:00:00.000Z', expiresAt: '2099-01-01T00:00:00.000Z' })
aw.seed('workorders', { $id: 'wo_t', customerId: 'c3', woid: '100401', topic: 'Einrichtung', einrichtungAbrechnung: 'abo' })
aw.seed('worksheets', { $id: 'ws_t', workorderId: 'wo_t', wsid: 'ws_t', countsToPlan: true, totalTime: 90, startDate: '10.09.2026' })
const res = await rawGet('/api/team/customers/c3/stundenkonto', auth)
assert.deepEqual([res.status, res.body], [200, { produkt: 'wartung', ledger: { balanceMinutes: 0, vorschussMinutes: 30, overageMinutes: 0, nextExpiry: null } }])
})
test('Hosting: kein Konto; ohne Abo null; beendetes Wartungs-Abo behält den Vorschuss, Guthaben 0; ohne JWT 401', async () => {
assert.deepEqual((await rawGet('/api/team/customers/c1/stundenkonto', auth)).body, { produkt: 'hosting', ledger: null }) // c1: Hosting aus seedFixtures
assert.deepEqual((await rawGet('/api/team/customers/c4/stundenkonto', auth)).body, { produkt: null, ledger: null })
await updateDocument('subscriptions', 'sub_t', { status: 'canceled' }) // aw.docs() liefert Kopien: ueber die API aendern
const ende = (await rawGet('/api/team/customers/c3/stundenkonto', auth)).body
assert.deepEqual([ende.produkt, ende.ledger.vorschussMinutes, ende.ledger.balanceMinutes], ['wartung', 30, 0])
assert.equal((await rawGet('/api/team/customers/c3/stundenkonto')).status, 401)
})
+14
View File
@@ -326,3 +326,17 @@ test('T23 und K34 (angenommen und abgelehnt) passen zu den Vorlagen: keine Platz
const k34 = renderMail('K34', aufrufe.kunde[1].values, aufrufe.kunde[1].blocks)
assert.match(k34.text, /199 €/)
})
const ansichtAlt = { ...config.kundenansichtNeu }; after(() => Object.assign(config.kundenansichtNeu, ansichtAlt))
const neueAnsicht = (an) => { config.kundenansichtNeu.mode = an ? 'pilot' : 'off'; config.kundenansichtNeu.pilotEmails = [customers.c1.email] }
test('Abo-Modell: Zusage als K43 ohne Preis und ohne „bestellen“, Absage unverändert; alte Ansicht bekommt weiter K34 mit 199 €', async () => {
for (const an of [true, false]) {
neueAnsicht(an); aufrufe.kunde.length = 0; await deleteDocument(config.collections.ukEinstellungen, FEATURE).catch(() => {}); assert.equal((await stelle()).status, 200); assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 200)
const m = aufrufe.kunde[0]; const r = renderMail(m.template, m.values, m.blocks); assert.deepEqual(r.problems, [])
assert.deepEqual([m.template, 'preis' in m.values, 'ust_hinweis' in m.values], an ? ['K43', false, false] : ['K34', true, true])
assert.equal(m.values.portal_url, `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=${P}`)
if (an) assert.doesNotMatch(r.text + r.html, /€|UStG|bestell/i); else { assert.match(r.text, /199 €/); assert.match(r.text, /bestellen/) }
}
neueAnsicht(true); aufrufe.kunde.length = 0; await deleteDocument(config.collections.ukEinstellungen, FEATURE).catch(() => {}); await stelle(); assert.equal((await entscheide({ entscheidung: 'ablehnen', begruendung: 'Passt nicht.' })).status, 200)
assert.deepEqual([aufrufe.kunde[0].template, aufrufe.kunde[0].values.begruendung, aufrufe.kunde[0].blocks], ['K43', 'Passt nicht.', { angenommen: false, abgelehnt: true }])
})
+7
View File
@@ -800,3 +800,10 @@ test('createFeatureCheckout: zweck nur behalten (sonst 400 vor jedem Stripe-Aufr
assert.equal(Object.keys(news).some((k) => k.includes('zweck')), false)
assert.equal(news.success_url, `${config.publicBaseUrl}/dashboard.html#${config.features.news.returnHash}?bestellt=1`)
})
test('Behalten (30 €) ist für Abo-Modell-Dokumente gesperrt (409 ohne Spur), für Gratis-Zeiten im selben Zustand möglich', async () => {
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
seedeAbgeschaltet({ einrichtungAbrechnung: 'abo' }); await mitZeiten()
const res = await behalten(koerper()); assert.deepEqual([res.status, res.body.error], [409, NICHT_MOEGLICH]); keineSpuren()
await updateDocument('portalFeatures', 'f_p1_zeiten', { einrichtungAbrechnung: '' }); assert.equal((await behalten(koerper())).status, 200)
})
+3 -2
View File
@@ -613,9 +613,10 @@ test('Ansicht features.zeiten: unlockedBy, aboEndeAt, einbau; loeschungAm nur be
const team = await ansicht({ state: 'disabled', unlockedBy: 'team', disabledReason: 'team', disabledAt: '2026-10-04T23:30:00.000Z' })
assert.equal(team.loeschungAm, null)
// andere Funktionen behalten ihre Ansicht
// andere Funktionen behalten ihre Ansicht; einzige Erweiterung (V7, Kundenansicht neu): unlockedBy gilt fuer jede Funktion (null solange sie aus ist)
const news = (await liste()).body.projects[0].features.news
assert.equal('einbau' in news || 'unlockedBy' in news || 'aboEndeAt' in news, false)
assert.equal('einbau' in news || 'aboEndeAt' in news, false)
assert.equal(news.unlockedBy, null)
})
// ----------------------------------------------------------------------------------------------------- Projektliste