Zustimmungsnachweis je Funktion (featureConsents) und Fristanker aus dem Klick

server/services/featureConsents.js speichert und liest den Nachweis zur Bestellung
einer Funktion (nur anfuegen, sieben Exporte): speichereZustimmung (Texte und Fassungen
als JSON, SHA-256 ueber die Texte, vollstaendig oder gar nicht), letzteZustimmung,
zustimmungFuerSitzung (leere Sitzung fragt nicht ab), leseZustimmung, texteHash,
zustimmungsfassungen und nachweisTexte (Haken, Belehrung, Knopf aus dem Register).
paidFeatureOrders liefert featureKey und vertragAt (Klick der bezahlten Sitzung, sonst
consentAt, sonst orderedAt; Lesefehler faellt mit Log auf orderedAt zurueck); der Widerruf
einer Funktion rechnet die Frist aus vertragAt. Eine spaetere, unbezahlte Zustimmung
verschiebt den Anker nicht.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 12:05:12 +00:00
1 parent d8e4d79891
commit 786df1230e
5 files changed
+627 -4

No files matched your search

+321
View File
@@ -0,0 +1,321 @@
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')
})
test('append-only: das Modul exportiert genau die sieben Lese- und Anfügefunktionen', () => {
assert.deepEqual(
Object.keys(consents).sort(),
['leseZustimmung', 'letzteZustimmung', 'nachweisTexte', '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)
}
})
+122 -1
View File
@@ -16,6 +16,7 @@ const aw = await startAppwriteMock({
contractEvents: schemaKeys('contractEvents'),
hourCredits: schemaKeys('hourCredits'),
portalFeatures: [...schemaKeys('portalFeatures'), 'siteKey', 'offSince', 'activatedAt', 'accentColor', 'removalTicketId'],
featureConsents: schemaKeys('featureConsents'),
},
})
const st = await startFakeStripe()
@@ -531,7 +532,127 @@ test('Widerruf Funktion: nichts automatisch, K04 „wir prüfen“, T09 nennt Fu
assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt)))
assert.match(team, /RECHTZEITIG/)
assert.doesNotMatch(team, /KEIN aktives Abo/)
aw.docs('portalFeatures').delete('f_pf1_news')
// Der Nachbau liefert aus docs() eine Kopie: geloescht wird ueber die API, sonst bliebe die Bestellung fuer die naechsten Tests
await deleteDocument('portalFeatures', 'f_pf1_news')
})
/** Zustimmungsnachweis zu einer Sitzung (featureConsents) fuer die Fristanker-Tests. */
function seedZustimmung(id, { projectId = 'pa1', stripeSessionId, acceptedAt, featureKey = 'termine' }) {
aw.seed('featureConsents', {
$id: id,
customerId: 'c1',
projectId,
featureKey,
stripeSessionId,
acceptedAt,
fassungen: '{}',
texte: '[]',
textHash: '',
})
}
/** Bezahlte Funktion mit Zustimmung zur bezahlten Sitzung (vertragAt) und neuerer, unbezahlter Zustimmung derselben Funktion. */
function seedBezahlteBestellung({ klickAt, orderedAt = daysAgo(1) }) {
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Studio Anker' })
aw.seed('portalFeatures', {
$id: 'f_pa1_termine',
customerId: 'c1',
projectId: 'pa1',
featureKey: 'termine',
state: 'setup',
enabled: false,
unlockedByPurchase: true,
// durch eine Neubestellung ueberschrieben: spaeter als der Klick der bezahlten Sitzung
orderedAt,
stripeSessionId: 'cs_bezahlt',
})
seedZustimmung('fc_bezahlt', { stripeSessionId: 'cs_bezahlt', acceptedAt: klickAt })
// spaetere, unbezahlte Zustimmung desselben Kunden und Projekts (andere Sitzung): verschiebt den Anker nicht
seedZustimmung('fc_offen', { stripeSessionId: 'cs_offen', acceptedAt: daysAgo(0.5) })
}
async function raeumeAnkerTest() {
await deleteDocument('portalFeatures', 'f_pa1_termine')
for (const id of ['fc_bezahlt', 'fc_offen']) if (aw.docs('featureConsents').has(id)) await deleteDocument('featureConsents', id)
}
test('Widerruf Funktion: Fristanker ist die Zeit des Klicks der bezahlten Sitzung (rechtzeitig)', async () => {
await resetSubH({ units: 0 })
const klickAt = daysAgo(13)
seedBezahlteBestellung({ klickAt })
try {
const res = await withdraw({ order: 'funktion' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const team = t09().text
// Frist aus dem Klick der bezahlten Sitzung, nicht aus orderedAt (vor 1 Tag) und nicht aus der spaeteren Zustimmung
assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team)
assert.ok(!team.includes(fmtDate(withdrawalDeadline(daysAgo(1)).endsAt)), 'nicht das Fristende aus orderedAt')
assert.match(team, /RECHTZEITIG/)
assert.doesNotMatch(team, /NACH Fristende/)
assert.match(team, /Studio Anker: in Einrichtung/)
} finally {
await raeumeAnkerTest()
}
})
test('Widerruf Funktion: Fristanker aus dem Klick vor 20 Tagen ergibt NACH Fristende', async () => {
await resetSubH({ units: 0 })
const klickAt = daysAgo(20)
seedBezahlteBestellung({ klickAt })
try {
const res = await withdraw({ order: 'funktion' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const team = t09().text
assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team)
assert.match(team, /NACH Fristende/)
assert.doesNotMatch(team, /RECHTZEITIG/)
} finally {
await raeumeAnkerTest()
}
})
test('Widerruf Funktion: ohne Nachweis zur Sitzung gilt consentAt, dann orderedAt', async () => {
await resetSubH({ units: 0 })
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Studio Anker' })
const base = { customerId: 'c1', projectId: 'pa1', featureKey: 'termine', state: 'setup', enabled: false, unlockedByPurchase: true, stripeSessionId: 'cs_ohne_nachweis' }
try {
// consentAt (Altbestand, News) vor orderedAt
aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(1), consentAt: daysAgo(20) })
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(20)).endsAt)))
assert.match(t09().text, /NACH Fristende/)
// weder Nachweis noch consentAt: orderedAt
mails.sent.length = 0
aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(3), consentAt: null })
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(3)).endsAt)))
assert.match(t09().text, /RECHTZEITIG/)
} finally {
await raeumeAnkerTest()
}
})
test('Widerruf Funktion: Nachweis-Collection nicht lesbar fällt auf orderedAt zurück, Antwort bleibt 200', async () => {
await resetSubH({ units: 0 })
const orderedAt = daysAgo(2)
seedBezahlteBestellung({ klickAt: daysAgo(20), orderedAt })
const warn = console.warn
const warnungen = []
console.warn = (...args) => warnungen.push(args.join(' '))
try {
aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500 })
const res = await withdraw({ order: 'funktion' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const team = t09().text
assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt)), team)
assert.match(team, /RECHTZEITIG/)
assert.ok(warnungen.some((w) => w.includes('Zustimmungsnachweis')), 'Rueckfall wird geloggt')
assert.ok(k04(), 'K04 geht trotzdem an den Kunden')
} finally {
console.warn = warn
await raeumeAnkerTest()
}
})
test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => {