POST /api/funktionen/zeiten/einschalten (Gratisweg, Spec 3.2): Abo frisch prüfen, Zustimmung (consentFrei) und Nachweis ohne Stripe-Sitzung, Zustand setup mit unlockedBy abo, dann Neubau, Einbau-Ticket und K24; ein zweiter Klick holt Fehlendes nach (holeZeitenNach, eigene Sperre). GET /api/funktionen liefert das Angebot (zeiten), features.zeiten mit unlockedBy, aboEndeAt, einbau, loeschungAm sowie zeitenStatus und zeitenLeer je Projekt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
16 KiB
JavaScript
333 lines
16 KiB
JavaScript
import { test, after, beforeEach } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { createHash } from 'node:crypto'
|
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
|
import { startFakeStripe } from './support/fakeStripe.js'
|
|
import { useTestEnv } from './support/testEnv.js'
|
|
import { schemaKeys } from './support/schema.js'
|
|
|
|
// Zustimmungsnachweis je Funktion (server/services/featureConsents.js, Collection featureConsents):
|
|
// nur anfuegen und lesen. Die Umgebung steht vor dem ersten Import von server/config.js.
|
|
const aw = await startAppwriteMock({ schema: { featureConsents: schemaKeys('featureConsents') } })
|
|
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 consents = await import('../server/services/featureConsents.js')
|
|
const { featureConfig } = await import('../server/services/featureRegistry.js')
|
|
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
|
const { paidFeatureOrders } = await import('../server/services/siteFeatures.js')
|
|
const { speichereZustimmung, letzteZustimmung, zustimmungFuerSitzung, leseZustimmung, texteHash, zustimmungsfassungen, nachweisTexte } = consents
|
|
|
|
const COLLECTION = config.collections.featureConsents
|
|
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
|
|
|
|
const TEXTE = [
|
|
{ id: 'unterlagen', text: 'Ich stimme der Leistungsbeschreibung zu.', haken: true },
|
|
{ id: 'beginn', text: 'Ich verlange den Beginn vor Ablauf der Frist.', haken: true },
|
|
{ id: 'knopf', text: 'Zahlungspflichtig bestellen · 149 €', haken: false },
|
|
]
|
|
const FASSUNGEN = { zustimmung: 'V1', leistungsbeschreibung: 'V1', avv: 'V1' }
|
|
|
|
function eintrag(patch = {}) {
|
|
return {
|
|
customerId: 'c1',
|
|
projectId: 'p1',
|
|
featureKey: 'termine',
|
|
stripeSessionId: 'cs_test_1',
|
|
fassungen: FASSUNGEN,
|
|
texte: TEXTE,
|
|
ip: '203.0.113.7',
|
|
userAgent: 'Testbrowser/1.0',
|
|
konto: 'Kundin <kundin@example.invalid>',
|
|
at: '2026-10-03T10:00:00.000Z',
|
|
...patch,
|
|
}
|
|
}
|
|
|
|
const zeilen = () => [...aw.docs(COLLECTION).values()]
|
|
|
|
// Jeder Test beginnt mit leerer Collection (der Nachbau liefert aus docs() eine Kopie; geloescht wird ueber die API)
|
|
beforeEach(async () => {
|
|
for (const id of aw.docs(COLLECTION).keys()) await deleteDocument(COLLECTION, id)
|
|
})
|
|
|
|
test('speichert und liest: Rundlauf mit Fassungen als Objekt, Texten als Array und unabhängig berechnetem Hash', async () => {
|
|
const doc = await speichereZustimmung(eintrag())
|
|
assert.ok(doc.$id)
|
|
assert.equal(doc.customerId, 'c1')
|
|
assert.equal(doc.projectId, 'p1')
|
|
assert.equal(doc.featureKey, 'termine')
|
|
assert.equal(doc.stripeSessionId, 'cs_test_1')
|
|
assert.equal(doc.acceptedAt, '2026-10-03T10:00:00.000Z')
|
|
assert.equal(doc.ip, '203.0.113.7')
|
|
assert.equal(doc.userAgent, 'Testbrowser/1.0')
|
|
assert.equal(doc.konto, 'Kundin <kundin@example.invalid>')
|
|
// Beides liegt als JSON-Text in der Collection
|
|
assert.equal(typeof doc.fassungen, 'string')
|
|
assert.equal(typeof doc.texte, 'string')
|
|
assert.equal(doc.texte, JSON.stringify(TEXTE))
|
|
assert.equal(doc.textHash, sha256(JSON.stringify(TEXTE)))
|
|
assert.equal(texteHash(TEXTE), sha256(JSON.stringify(TEXTE)))
|
|
|
|
const gelesen = leseZustimmung(zeilen()[0])
|
|
assert.deepEqual(gelesen.fassungen, FASSUNGEN)
|
|
assert.deepEqual(gelesen.texte, TEXTE)
|
|
assert.equal(gelesen.textHash, doc.textHash)
|
|
assert.equal(gelesen.$id, doc.$id)
|
|
assert.equal(gelesen.stripeSessionId, 'cs_test_1')
|
|
// leseZustimmung ändert das gespeicherte Dokument nicht
|
|
assert.equal(typeof zeilen()[0].texte, 'string')
|
|
})
|
|
|
|
test('speichert den Zeitpunkt aus einem Date-Objekt als ISO-Text', async () => {
|
|
const doc = await speichereZustimmung(eintrag({ at: new Date('2026-10-03T12:34:56.789Z') }))
|
|
assert.equal(doc.acceptedAt, '2026-10-03T12:34:56.789Z')
|
|
})
|
|
|
|
// Dazu seit Aufgabe 16 der Nachweis aus einer Anfrage (speichereNachweisAusAnfrage, Checkout und Gratisweg) und die handelnde Person (actingPerson)
|
|
test('append-only: das Modul exportiert genau die Lese- und Anfügefunktionen', () => {
|
|
assert.deepEqual(
|
|
Object.keys(consents).sort(),
|
|
[
|
|
'actingPerson',
|
|
'leseZustimmung',
|
|
'letzteZustimmung',
|
|
'nachweisTexte',
|
|
'speichereNachweisAusAnfrage',
|
|
'speichereZustimmung',
|
|
'texteHash',
|
|
'zustimmungFuerSitzung',
|
|
'zustimmungsfassungen',
|
|
]
|
|
)
|
|
assert.equal(Object.keys(consents).filter((name) => /update|delete|remove|aender|loesch/i.test(name)).length, 0)
|
|
})
|
|
|
|
test('neueste Zustimmung: nach acceptedAt, andere Funktion und anderes Projekt stören nicht', async () => {
|
|
// Reihenfolge des Anlegens ist absichtlich nicht die Zeitreihenfolge
|
|
const spaeter = await speichereZustimmung(eintrag({ stripeSessionId: 'cs_b', at: '2026-10-03T12:00:00.000Z' }))
|
|
await speichereZustimmung(eintrag({ stripeSessionId: 'cs_a', at: '2026-10-03T09:00:00.000Z' }))
|
|
// spätere Einträge, die nicht zählen dürfen
|
|
await speichereZustimmung(eintrag({ stripeSessionId: 'cs_n', featureKey: 'news', at: '2026-10-04T09:00:00.000Z' }))
|
|
await speichereZustimmung(eintrag({ stripeSessionId: 'cs_p', projectId: 'p2', at: '2026-10-04T10:00:00.000Z' }))
|
|
await speichereZustimmung(eintrag({ stripeSessionId: 'cs_c', customerId: 'c2', at: '2026-10-04T11:00:00.000Z' }))
|
|
|
|
const neueste = await letzteZustimmung({ customerId: 'c1', projectId: 'p1', featureKey: 'termine' })
|
|
assert.equal(neueste.$id, spaeter.$id)
|
|
assert.equal(neueste.stripeSessionId, 'cs_b')
|
|
|
|
assert.equal((await letzteZustimmung({ customerId: 'c1', projectId: 'p1', featureKey: 'news' })).stripeSessionId, 'cs_n')
|
|
assert.equal((await letzteZustimmung({ customerId: 'c1', projectId: 'p2', featureKey: 'termine' })).stripeSessionId, 'cs_p')
|
|
assert.equal(await letzteZustimmung({ customerId: 'c1', projectId: 'p9', featureKey: 'termine' }), null)
|
|
})
|
|
|
|
test('neueste Zustimmung: unvollständige Angaben liefern null ohne Abfrage', async () => {
|
|
await speichereZustimmung(eintrag())
|
|
aw.resetListCalls()
|
|
assert.equal(await letzteZustimmung({ customerId: 'c1', projectId: '', featureKey: 'termine' }), null)
|
|
assert.equal(await letzteZustimmung({ customerId: 'c1', featureKey: 'termine' }), null)
|
|
assert.equal(await letzteZustimmung({}), null)
|
|
assert.equal(aw.listCalls(COLLECTION).length, 0)
|
|
})
|
|
|
|
test('zustimmungFuerSitzung: findet den Nachweis genau dieser Sitzung, sonst null; Lesefehler werfen', async () => {
|
|
const a = await speichereZustimmung(eintrag({ stripeSessionId: 'cs_a', at: '2026-10-03T09:00:00.000Z' }))
|
|
const b = await speichereZustimmung(eintrag({ stripeSessionId: 'cs_b', at: '2026-10-03T12:00:00.000Z' }))
|
|
assert.equal((await zustimmungFuerSitzung('cs_a')).$id, a.$id)
|
|
assert.equal((await zustimmungFuerSitzung('cs_b')).$id, b.$id)
|
|
assert.equal(await zustimmungFuerSitzung('cs_unbekannt'), null)
|
|
|
|
aw.failNext({ collection: COLLECTION, method: 'GET', status: 500 })
|
|
await assert.rejects(() => zustimmungFuerSitzung('cs_a'))
|
|
})
|
|
|
|
test('vollständig oder gar nicht: zu lange Texte werfen 400 und es wird nichts geschrieben', async () => {
|
|
const lang = [{ id: 'x', text: 'a'.repeat(20000), haken: true }]
|
|
assert.ok(JSON.stringify(lang).length > 20000)
|
|
await assert.rejects(() => speichereZustimmung(eintrag({ texte: lang })), (err) => err.status === 400)
|
|
assert.equal(zeilen().length, 0)
|
|
|
|
// genau am Rand wird nichts gekürzt: passt der JSON-Text, kommt er vollständig an
|
|
const textLaenge = 20000 - JSON.stringify([{ id: 'x', text: '', haken: true }]).length
|
|
const passend = [{ id: 'x', text: 'b'.repeat(textLaenge), haken: true }]
|
|
assert.equal(JSON.stringify(passend).length, 20000)
|
|
const doc = await speichereZustimmung(eintrag({ texte: passend }))
|
|
assert.equal(doc.texte, JSON.stringify(passend))
|
|
assert.deepEqual(leseZustimmung(doc).texte, passend)
|
|
})
|
|
|
|
test('vollständig oder gar nicht: zu lange Fassungen, fehlendes oder ungültiges at, leere oder ungültige Texte werfen', async () => {
|
|
const vorher = zeilen().length
|
|
const bad = async (patch, status = 400) =>
|
|
assert.rejects(() => speichereZustimmung(eintrag(patch)), (err) => err.status === status, JSON.stringify(Object.keys(patch)))
|
|
|
|
await bad({ fassungen: { zustimmung: 'v'.repeat(1000) } })
|
|
await bad({ fassungen: null })
|
|
await bad({ fassungen: ['V1'] })
|
|
await bad({ fassungen: undefined })
|
|
|
|
await bad({ at: undefined })
|
|
await bad({ at: null })
|
|
await bad({ at: '' })
|
|
await bad({ at: 'gestern' })
|
|
await bad({ at: new Date('kaputt') })
|
|
await bad({ at: 1759485600000 })
|
|
|
|
await bad({ texte: [] })
|
|
await bad({ texte: undefined })
|
|
await bad({ texte: 'Text' })
|
|
await bad({ texte: [{ id: 'a', text: '' }] })
|
|
await bad({ texte: [{ id: '', text: 'Text' }] })
|
|
await bad({ texte: [{ id: 'a', text: 'Text', haken: 'ja' }] })
|
|
await bad({ texte: ['Text'] })
|
|
await bad({ texte: [null] })
|
|
|
|
await bad({ customerId: '' })
|
|
await bad({ projectId: undefined })
|
|
await bad({ featureKey: '' })
|
|
await bad({ featureKey: 'x'.repeat(17) })
|
|
await bad({ stripeSessionId: 's'.repeat(129) })
|
|
|
|
assert.equal(zeilen().length, vorher, 'nichts geschrieben')
|
|
})
|
|
|
|
test('IP, User-Agent und Konto werden auf 64, 300 und 255 Zeichen gekürzt', async () => {
|
|
const doc = await speichereZustimmung(eintrag({ ip: 'i'.repeat(100), userAgent: 'u'.repeat(400), konto: 'k'.repeat(300) }))
|
|
assert.equal(doc.ip, 'i'.repeat(64))
|
|
assert.equal(doc.userAgent, 'u'.repeat(300))
|
|
assert.equal(doc.konto, 'k'.repeat(255))
|
|
// Fehlende Angaben werden leer
|
|
const leer = await speichereZustimmung(eintrag({ ip: undefined, userAgent: null, konto: undefined, stripeSessionId: 'cs_leer' }))
|
|
assert.equal(leer.ip, '')
|
|
assert.equal(leer.userAgent, '')
|
|
assert.equal(leer.konto, '')
|
|
})
|
|
|
|
test('Nachweistexte aus dem Register: Haken in Anzeigereihenfolge, zuletzt der Knopf; Belehrung direkt davor', () => {
|
|
const termine = featureConfig('termine')
|
|
const texte = nachweisTexte(termine)
|
|
assert.equal(texte.length, 5)
|
|
assert.deepEqual(
|
|
texte.map((t) => t.id),
|
|
[...termine.consent.items.map((i) => i.id), 'knopf']
|
|
)
|
|
assert.deepEqual(texte.slice(0, 4), termine.consent.items.map((i) => ({ id: i.id, text: i.text, haken: true })))
|
|
assert.deepEqual(texte.at(-1), { id: 'knopf', text: termine.consent.knopf, haken: false })
|
|
|
|
const mit = nachweisTexte(termine, { belehrung: 'Text' })
|
|
assert.equal(mit.length, 6)
|
|
assert.deepEqual(mit.at(-2), { id: 'belehrung', text: 'Text', haken: false })
|
|
assert.deepEqual(mit.at(-1), { id: 'knopf', text: termine.consent.knopf, haken: false })
|
|
assert.deepEqual(mit.slice(0, 4), texte.slice(0, 4))
|
|
// ohne oder mit null: keine Belehrung
|
|
assert.deepEqual(nachweisTexte(termine, { belehrung: null }), texte)
|
|
|
|
// News: ein Haken und der Knopf
|
|
const news = featureConfig('news')
|
|
const newsTexte = nachweisTexte(news)
|
|
assert.deepEqual(newsTexte.map((t) => t.id), ['beginn', 'knopf'])
|
|
assert.equal(newsTexte[0].text, news.consent.items[0].text)
|
|
assert.equal(newsTexte[1].text, news.consent.knopf)
|
|
|
|
// Eine übergebene Belehrung muss ein nichtleerer Text sein (sonst fehlte ein gezeigter Text im Nachweis)
|
|
assert.throws(() => nachweisTexte(termine, { belehrung: '' }))
|
|
assert.throws(() => nachweisTexte(termine, { belehrung: { text: 'x' } }))
|
|
})
|
|
|
|
test('Fassungen aus dem Register: Zustimmung plus die vorhandenen Fassungsfelder', () => {
|
|
const news = featureConfig('news')
|
|
assert.deepEqual(zustimmungsfassungen(news), { zustimmung: news.consent.version, leistungsbeschreibung: news.leistungsbeschreibung.version })
|
|
const termine = featureConfig('termine')
|
|
assert.deepEqual(zustimmungsfassungen(termine), {
|
|
zustimmung: termine.consent.version,
|
|
leistungsbeschreibung: termine.leistungsbeschreibung.version,
|
|
avv: termine.avv.version,
|
|
})
|
|
// widerruf kommt dazu, sobald das Register es führt
|
|
const mitWiderruf = { ...termine, widerruf: { version: 'W1', url: '/x' } }
|
|
assert.deepEqual(zustimmungsfassungen(mitWiderruf), {
|
|
zustimmung: termine.consent.version,
|
|
leistungsbeschreibung: termine.leistungsbeschreibung.version,
|
|
avv: termine.avv.version,
|
|
widerruf: 'W1',
|
|
})
|
|
// Das Ergebnis passt in den Nachweis
|
|
assert.ok(JSON.stringify(zustimmungsfassungen(mitWiderruf)).length <= 1000)
|
|
})
|
|
|
|
test('Staff-Nachweis: ohne Sitzung, IP, User-Agent und Haken gespeichert; zustimmungFuerSitzung("") fragt nicht ab', async () => {
|
|
const doc = await speichereZustimmung(
|
|
eintrag({
|
|
stripeSessionId: '',
|
|
ip: '',
|
|
userAgent: '',
|
|
konto: 'Mitarbeiter: Kenso',
|
|
fassungen: { leistungsbeschreibung: 'V1', avv: 'V1' },
|
|
texte: [{ id: 'textform', text: 'Der Kunde hat in Textform zugestimmt.' }],
|
|
})
|
|
)
|
|
assert.equal(doc.stripeSessionId, '')
|
|
assert.equal(doc.ip, '')
|
|
assert.equal(doc.userAgent, '')
|
|
assert.equal(doc.konto, 'Mitarbeiter: Kenso')
|
|
assert.deepEqual(leseZustimmung(doc).texte, [{ id: 'textform', text: 'Der Kunde hat in Textform zugestimmt.' }])
|
|
assert.equal(doc.textHash, sha256(JSON.stringify([{ id: 'textform', text: 'Der Kunde hat in Textform zugestimmt.' }])))
|
|
|
|
// Die Zeile mit leerer Sitzung existiert, darf aber über keine Sitzungsabfrage getroffen werden
|
|
assert.equal(zeilen().length, 1)
|
|
aw.resetListCalls()
|
|
assert.equal(await zustimmungFuerSitzung(''), null)
|
|
assert.equal(await zustimmungFuerSitzung(undefined), null)
|
|
assert.equal(await zustimmungFuerSitzung(null), null)
|
|
assert.equal(await zustimmungFuerSitzung(' '), null)
|
|
assert.equal(aw.listCalls(COLLECTION).length, 0, 'keine Listenabfrage an featureConsents')
|
|
|
|
// und sie steht als neueste Zustimmung des Projekts zur Verfügung
|
|
assert.equal((await letzteZustimmung({ customerId: 'c1', projectId: 'p1', featureKey: 'termine' })).$id, doc.$id)
|
|
})
|
|
|
|
test('texteHash: SHA-256 über JSON.stringify der Texte in der gegebenen Reihenfolge', () => {
|
|
const umgekehrt = [...TEXTE].reverse()
|
|
assert.notEqual(texteHash(umgekehrt), texteHash(TEXTE))
|
|
assert.match(texteHash(TEXTE), /^[0-9a-f]{64}$/)
|
|
})
|
|
|
|
test('paidFeatureOrders: featureKey und vertragAt (Nachweis zur bezahlten Sitzung, sonst consentAt, sonst orderedAt)', async () => {
|
|
const gebucht = (patch) => ({ customerId: 'c1', enabled: false, unlockedByPurchase: true, ...patch })
|
|
aw.seed('portalFeatures', gebucht({ $id: 'f_a', projectId: 'pa', featureKey: 'termine', state: 'setup', orderedAt: '2026-10-03T08:00:00.000Z', consentAt: '2026-10-01T08:00:00.000Z', stripeSessionId: 'cs_bezahlt' }))
|
|
aw.seed('portalFeatures', gebucht({ $id: 'f_b', projectId: 'pb', featureKey: 'news', state: 'active', orderedAt: '2026-09-30T08:00:00.000Z', consentAt: '2026-09-29T08:00:00.000Z', stripeSessionId: 'cs_alt' }))
|
|
aw.seed('portalFeatures', gebucht({ $id: 'f_c', projectId: 'pc', featureKey: 'news', state: 'disabled', orderedAt: '2026-09-28T08:00:00.000Z', stripeSessionId: '' }))
|
|
aw.seed('portalFeatures', gebucht({ $id: 'f_d', projectId: 'pd', featureKey: 'termine', state: 'ordered', orderedAt: '2026-10-03T09:00:00.000Z', unlockedByPurchase: false, stripeSessionId: 'cs_offen' }))
|
|
aw.seed('portalFeatures', gebucht({ $id: 'f_e', customerId: 'c2', projectId: 'pe', featureKey: 'termine', state: 'setup', orderedAt: '2026-10-03T09:00:00.000Z', stripeSessionId: 'cs_fremd' }))
|
|
// Klick der bezahlten Sitzung; dazu eine spätere, unbezahlte Zustimmung desselben Kunden/Projekts
|
|
await speichereZustimmung(eintrag({ projectId: 'pa', stripeSessionId: 'cs_bezahlt', at: '2026-09-20T10:00:00.000Z' }))
|
|
await speichereZustimmung(eintrag({ projectId: 'pa', stripeSessionId: 'cs_zweite', at: '2026-10-03T07:00:00.000Z' }))
|
|
try {
|
|
aw.resetListCalls()
|
|
const orders = await paidFeatureOrders('c1')
|
|
assert.deepEqual(
|
|
orders.map((o) => [o.featureKey, o.state, o.vertragAt]),
|
|
[
|
|
['termine', 'setup', '2026-09-20T10:00:00.000Z'],
|
|
['news', 'active', '2026-09-29T08:00:00.000Z'],
|
|
['news', 'disabled', '2026-09-28T08:00:00.000Z'],
|
|
]
|
|
)
|
|
assert.deepEqual(orders.map((o) => o.orderedAt), ['2026-10-03T08:00:00.000Z', '2026-09-30T08:00:00.000Z', '2026-09-28T08:00:00.000Z'])
|
|
|
|
// Nachweis-Collection nicht lesbar: Rückfall auf orderedAt (nicht auf das Zustimmungsdatum), kein Fehler
|
|
const warn = console.warn
|
|
console.warn = () => {}
|
|
try {
|
|
aw.failNext({ collection: COLLECTION, method: 'GET', status: 500 })
|
|
const kaputt = await paidFeatureOrders('c1')
|
|
assert.deepEqual(kaputt.map((o) => o.vertragAt), ['2026-10-03T08:00:00.000Z', '2026-09-29T08:00:00.000Z', '2026-09-28T08:00:00.000Z'])
|
|
} finally {
|
|
console.warn = warn
|
|
}
|
|
} finally {
|
|
for (const id of ['f_a', 'f_b', 'f_c', 'f_d', 'f_e']) await deleteDocument(config.collections.portalFeatures, id)
|
|
}
|
|
})
|