229 lines
13 KiB
JavaScript
229 lines
13 KiB
JavaScript
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
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'
|
|
|
|
// Rechtstext-Korrekturen der News-Pinnwand (Task F2): Zitat § 356 Abs. 5 Nr. 2, Wertersatz-Satz, § 19 UStG statt "brutto".
|
|
// Die Wortlaute sind hier als Literale eingefroren (Wortlaut-Pin); die Fassung folgt config.termine.avvVersion.
|
|
const aw = await startAppwriteMock({
|
|
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents') },
|
|
})
|
|
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, seedFixtures } = await import('./support/fixtures.js')
|
|
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
|
|
const sf = await import('../server/services/siteFeatures.js')
|
|
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
|
const { mailSink } = await import('./support/mailSink.js')
|
|
|
|
const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
|
|
const mails = mailSink()
|
|
setMailTransportForTests(mails)
|
|
after(() => {
|
|
setMailTransportForTests(null)
|
|
return Promise.all([app.close(), aw.close(), st.close()])
|
|
})
|
|
seedFixtures(aw)
|
|
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' })
|
|
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café' })
|
|
const cookie = customerCookie('c1')
|
|
|
|
const root = (rel) => new URL(`../${rel}`, import.meta.url)
|
|
const lesen = (rel) => readFileSync(root(rel), 'utf8')
|
|
|
|
const NEWS_SATZ =
|
|
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).'
|
|
const ALTER_SATZ =
|
|
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).'
|
|
const USTHINWEIS = 'ohne Umsatzsteuer gemäß § 19 UStG'
|
|
|
|
test('news_zustimmung_wortlaut: Register = Alias = exakter Text mit Abs. 5 Nr. 2 und § 357a Abs. 2, Fassung = <F>', () => {
|
|
const news = config.features.news
|
|
assert.deepEqual(news.consent.items, [{ id: 'beginn', haken: true, text: NEWS_SATZ }])
|
|
assert.equal(config.funktionen.consentText, NEWS_SATZ)
|
|
assert.equal(news.consent.items[0].text, config.funktionen.consentText)
|
|
assert.ok(NEWS_SATZ.includes('Abs. 5 Nr. 2') && NEWS_SATZ.includes('§ 357a Abs. 2'))
|
|
assert.ok(!NEWS_SATZ.includes('Abs. 4'))
|
|
assert.equal(news.consent.knopf, 'Zahlungspflichtig bestellen · 30 €')
|
|
// die Fassung folgt der Konstante <F> (zur Laufzeit), auch die der Leistungsbeschreibung, deren Stand sich mit dem Text ändert
|
|
assert.equal(news.consent.version, config.termine.avvVersion)
|
|
assert.equal(news.leistungsbeschreibung.version, config.termine.avvVersion)
|
|
assert.ok(news.consent.version > '2026-10-02')
|
|
const alt = config.termine.avvVersion
|
|
config.termine.avvVersion = '2099-01-02'
|
|
try {
|
|
assert.equal(news.consent.version, '2099-01-02')
|
|
assert.equal(news.leistungsbeschreibung.version, '2099-01-02')
|
|
} finally {
|
|
config.termine.avvVersion = alt
|
|
}
|
|
assert.equal(news.leistungsbeschreibung.url, '/vertragsunterlagen/funktionen-leistungsbeschreibung.html')
|
|
assert.equal(news.mails.preisHinweis, `einmalig, ${USTHINWEIS}; die Nutzung ist kostenlos, es läuft kein Abo`)
|
|
})
|
|
|
|
test('waechter_zitat_und_preistext: kein altes Zitat, keine Mehrwertsteuer-Angabe, kein "brutto" in den Kundentexten', () => {
|
|
const dateien = [
|
|
'server/config.js',
|
|
'public/vertragsunterlagen/funktionen-leistungsbeschreibung.html',
|
|
'public/funktionen.js',
|
|
'server/mail-templates/K15 Bestellung Funktion.html',
|
|
'server/services/stripeBilling.js',
|
|
]
|
|
for (const datei of dateien) {
|
|
const text = lesen(datei)
|
|
assert.doesNotMatch(text, /§ 356 Abs\. 4/, `${datei}: altes Zitat`)
|
|
assert.doesNotMatch(text, /inklusive (Mehr|Umsatz)steuer/i, `${datei}: inklusive Steuer`)
|
|
assert.doesNotMatch(text, /inklusive Mehrwertsteuer/i, `${datei}: inklusive Mehrwertsteuer`)
|
|
assert.doesNotMatch(text, /\bbrutto\b/i, `${datei}: brutto`)
|
|
}
|
|
const lb = lesen('public/vertragsunterlagen/funktionen-leistungsbeschreibung.html')
|
|
assert.equal(lb.split(USTHINWEIS).length - 1, 2, 'LB nennt den Hinweis in Kopf-Tabelle und Abschnitt 2')
|
|
assert.ok(lb.includes('<td>30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), keine laufenden Kosten</td>'))
|
|
assert.ok(lb.includes('Der Einbau kostet 30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG).'))
|
|
assert.ok(lb.includes('erlischt nach § 356 Abs. 5 Nr. 2 BGB'))
|
|
assert.ok(lb.includes('(§ 357a Abs. 2 BGB)'))
|
|
assert.ok(lb.includes('<!-- ENTWURF, Freigabe Nutzer ausstehend -->'), 'die Freigabe bleibt Sache des Inhabers')
|
|
// der Stand der LB ist der Tag der Fassung <F>
|
|
const [j, m, t] = config.termine.avvVersion.split('-')
|
|
assert.ok(lb.includes(`<p class="meta">Stand ${t}.${m}.${j}</p>`), 'Stand der LB = config.termine.avvVersion')
|
|
})
|
|
|
|
test('bestellseite_hinweis: GET /api/funktionen liefert ustHinweis', async () => {
|
|
const body = await get('/api/funktionen', { cookie })
|
|
assert.equal(body.ustHinweis, config.ustHinweis)
|
|
assert.equal(config.ustHinweis, USTHINWEIS)
|
|
assert.equal(body.consentText, NEWS_SATZ)
|
|
assert.equal(body.features.news.consent.items[0].text, NEWS_SATZ)
|
|
assert.equal(body.features.news.consent.version, config.termine.avvVersion)
|
|
})
|
|
|
|
// ---- Client: public/funktionen.js wirklich laden (absolute Importe auf die Dateien umleiten) und die Buchungsseite rendern
|
|
const dir = mkdtempSync(join(tmpdir(), 'fn-recht-'))
|
|
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
|
|
const clientQuelle = lesen('public/funktionen.js')
|
|
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
|
|
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
|
|
.replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`)
|
|
const clientDatei = join(dir, 'funktionen.mjs')
|
|
writeFileSync(clientDatei, clientQuelle)
|
|
const { initNews } = await import(pathToFileURL(clientDatei).href)
|
|
|
|
function fakeEl() {
|
|
const handlers = {}
|
|
return { innerHTML: '', textContent: '', disabled: false, checked: false, value: '', dataset: {}, classList: { add() {}, remove() {}, toggle() {} }, handlers, addEventListener(ev, fn) { handlers[ev] = fn }, querySelector: () => null, querySelectorAll: () => [] }
|
|
}
|
|
async function renderBuchung(antwort) {
|
|
const echtesFetch = globalThis.fetch
|
|
globalThis.window = { location: { hash: '#news', pathname: '/dashboard.html', search: '', href: '' }, history: { replaceState() {} } }
|
|
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => antwort })
|
|
const els = {}
|
|
const stage = { innerHTML: '', els, querySelector: (sel) => (els[sel] ||= fakeEl()), querySelectorAll: () => [] }
|
|
const rootEl = fakeEl()
|
|
rootEl.querySelector = (sel) => (sel === '#news-stage' ? stage : null)
|
|
try {
|
|
await initNews(rootEl, { projectId: 'p1' })
|
|
return stage
|
|
} finally {
|
|
delete globalThis.window
|
|
globalThis.fetch = echtesFetch
|
|
}
|
|
}
|
|
const frei = { projectId: 'p1', projectName: 'Muster', features: { news: { state: 'off' } } }
|
|
|
|
test('bestellseite_hinweis (Client): Preis mit Hinweis aus ustHinweis, per textContent; Rückfall = config.ustHinweis; kein "inklusive Mehrwertsteuer"', async () => {
|
|
const stage = await renderBuchung({ projects: [frei], consentText: NEWS_SATZ, ustHinweis: 'Testhinweis § 19' })
|
|
assert.match(stage.innerHTML, /<strong>30 €<\/strong>/)
|
|
assert.doesNotMatch(stage.innerHTML, /Mehrwertsteuer|inklusive|brutto/i)
|
|
assert.doesNotMatch(stage.innerHTML, /Testhinweis/, 'der Hinweis steht nie im HTML-Text, nur als textContent')
|
|
assert.equal(stage.els['#news-ust-hinweis'].textContent, 'Testhinweis § 19')
|
|
assert.match(stage.innerHTML, /einmalig, <span id="news-ust-hinweis"><\/span>/)
|
|
|
|
// ein Hinweis mit Markup bleibt Text
|
|
const bose = await renderBuchung({ projects: [frei], ustHinweis: '<img src=x onerror=1>' })
|
|
assert.doesNotMatch(bose.innerHTML, /<img/)
|
|
assert.equal(bose.els['#news-ust-hinweis'].textContent, '<img src=x onerror=1>')
|
|
|
|
// fehlt der Hinweis in der Antwort, gilt der Rückfall im Client: derselbe Wortlaut wie config.ustHinweis
|
|
const rueck = await renderBuchung({ projects: [frei], consentText: NEWS_SATZ })
|
|
assert.equal(rueck.els['#news-ust-hinweis'].textContent, config.ustHinweis)
|
|
for (const leer of ['', ' ', 5, null]) {
|
|
const r = await renderBuchung({ projects: [frei], ustHinweis: leer })
|
|
assert.equal(r.els['#news-ust-hinweis'].textContent, config.ustHinweis, String(leer))
|
|
}
|
|
})
|
|
|
|
// ---- K15 (Bestellbestätigung) der News
|
|
const paidEvent = (sessionId, projectId) => ({
|
|
id: `evt_${sessionId}`,
|
|
object: 'event',
|
|
type: 'checkout.session.completed',
|
|
data: { object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId: 'c1', projectId, featureKey: 'news' } } },
|
|
})
|
|
|
|
test('k15_news: Preiszeile mit § 19 UStG statt "brutto"', async () => {
|
|
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Bistro' })
|
|
await sf.markOrdered({ customerId: 'c1', projectId: 'p3', key: 'news', sessionId: 'cs_k15', consent: {} })
|
|
const vor = mails.sent.length
|
|
await sf.handleFeatureCheckoutEvent(paidEvent('cs_k15', 'p3'))
|
|
const k15 = mails.sent.slice(vor).filter((m) => m.subject.startsWith('Deine Bestellung'))
|
|
assert.equal(k15.length, 1)
|
|
assert.ok(k15[0].text.includes(`Einmalig bezahlt: 30,00 € (einmalig, ${USTHINWEIS}; die Nutzung ist kostenlos, es läuft kein Abo)`), k15[0].text)
|
|
assert.doesNotMatch(k15[0].text, /brutto/i)
|
|
assert.doesNotMatch(k15[0].html, /brutto/i)
|
|
assert.ok(k15[0].html.includes(USTHINWEIS))
|
|
})
|
|
|
|
// ---- bestehende Bestellungen behalten Text und Fassung; neue speichern die Register-Werte
|
|
test('alte_bestellung_unveraendert: altes Dokument bleibt, neue Bestellung speichert Register-Text und Fassung', async () => {
|
|
aw.seed('portalFeatures', {
|
|
$id: 'f_p2_news', // feste ID je (Projekt, Funktion)
|
|
enabled: true,
|
|
unlockedByPurchase: true,
|
|
customerId: 'c1',
|
|
projectId: 'p2',
|
|
featureKey: 'news',
|
|
state: 'active',
|
|
purchaseStatus: 'paid',
|
|
consentText: ALTER_SATZ,
|
|
consentAt: '2026-09-30T10:00:00.000Z',
|
|
orderedAt: '2026-09-30T10:00:00.000Z',
|
|
})
|
|
aw.seed('featureConsents', {
|
|
$id: 'fc_alt',
|
|
customerId: 'c1',
|
|
projectId: 'p2',
|
|
featureKey: 'news',
|
|
stripeSessionId: 'cs_alt',
|
|
acceptedAt: '2026-09-30T10:00:00.000Z',
|
|
fassungen: JSON.stringify({ zustimmung: '2026-09-30', leistungsbeschreibung: '2026-09-30' }),
|
|
texte: JSON.stringify([{ id: 'beginn', text: ALTER_SATZ, haken: true }]),
|
|
})
|
|
const alt = await sf.getFeatureDoc('c1', 'p2', 'news')
|
|
assert.equal(alt.consentText, ALTER_SATZ)
|
|
const liste = await get('/api/funktionen', { cookie })
|
|
assert.equal(liste.projects.find((p) => p.projectId === 'p2').features.news.state, 'active')
|
|
|
|
const res = await post('/api/funktionen/news/checkout', { projectId: 'p1', consent: true }, { cookie })
|
|
assert.equal(res.status, 200, JSON.stringify(res.body))
|
|
const neu = await sf.getFeatureDoc('c1', 'p1', 'news')
|
|
assert.equal(neu.consentText, NEWS_SATZ)
|
|
const nachweise = [...aw.docs('featureConsents').values()].filter((d) => d.projectId === 'p1')
|
|
assert.equal(nachweise.length, 1)
|
|
assert.deepEqual(JSON.parse(nachweise[0].fassungen), { zustimmung: config.termine.avvVersion, leistungsbeschreibung: config.termine.avvVersion })
|
|
const texte = JSON.parse(nachweise[0].texte)
|
|
assert.equal(texte[0].text, NEWS_SATZ)
|
|
|
|
// das alte Dokument und sein Nachweis sind unverändert
|
|
assert.equal((await sf.getFeatureDoc('c1', 'p2', 'news')).consentText, ALTER_SATZ)
|
|
assert.deepEqual(JSON.parse(aw.docs('featureConsents').get('fc_alt').fassungen), { zustimmung: '2026-09-30', leistungsbeschreibung: '2026-09-30' })
|
|
assert.equal(JSON.parse(aw.docs('featureConsents').get('fc_alt').texte)[0].text, ALTER_SATZ)
|
|
})
|