Files
Webklar-Kundenbereich/test/contractEvents.test.js
T

216 lines
8.8 KiB
JavaScript

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'
const aw = await startAppwriteMock({
schema: { contractEvents: schemaKeys('contractEvents'), subscriptions: schemaKeys('subscriptions') },
})
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const {
recordContractEvent,
listContractEvents,
openWithdrawals,
listOpenStorageTasks,
completeContractEvent,
updateContractEvent,
eventIdFor,
getContractEvent,
} = await import('../server/services/contractEvents.js')
test('Ereignis speichern und lesen (details als JSON)', async () => {
await recordContractEvent({
customerId: 'c1',
subscriptionId: 'sub_1',
type: 'speicher',
productKey: 'hosting',
details: { fromUnits: 0, toUnits: 2 },
consent: {
termsVersion: '2026-10-05-v4',
consentAt: '2026-10-05T10:00:00.000Z',
consentIp: '1.2.3.4',
consentUserAgent: 'UA',
},
status: 'offen',
dueAt: '2026-10-05T10:00:00.000Z',
})
const [e] = await listContractEvents('c1')
assert.deepEqual(e.details, { fromUnits: 0, toUnits: 2 })
// Zweites offenes Speicher-Ereignis mit frueherem dueAt, absichtlich NACH dem ersten angelegt
// (umgekehrte Reihenfolge): listOpenStorageTasks() muss trotzdem nach dueAt aufsteigend sortieren,
// nicht nach der Anlage-Reihenfolge.
await recordContractEvent({
customerId: 'c5',
subscriptionId: 'sub_5',
type: 'speicher',
productKey: 'hosting',
details: { fromUnits: 0, toUnits: 1 },
consent: {
termsVersion: '2026-10-05-v4',
consentAt: '2026-10-02T10:00:00.000Z',
consentIp: '1.2.3.4',
consentUserAgent: 'UA',
},
status: 'offen',
dueAt: '2026-10-03T10:00:00.000Z',
})
const tasks = await listOpenStorageTasks()
assert.equal(tasks.length, 2)
// Wichtig: details kommt geparst zurueck, wie bei listContractEvents (nicht als JSON-String)
assert.equal(tasks[0].details.toUnits, 1)
// Minor: faelligstes (frueheres dueAt) zuerst, unabhaengig von der Anlage-Reihenfolge
assert.deepEqual(tasks.map((t) => t.dueAt), ['2026-10-03T10:00:00.000Z', '2026-10-05T10:00:00.000Z'])
})
test('Widerrufsfristen je Bestellung', () => {
const ev = (type, consentAt, details = {}, status = 'erledigt') => ({ $id: type, type, consentAt, details, status })
const now = new Date('2026-10-10T12:00:00Z')
const open = openWithdrawals(
[
ev('abschluss', '2026-09-01T10:00:00Z'),
ev('wechsel', '2026-10-05T10:00:00Z'),
ev('speicher', '2026-10-06T10:00:00Z', { fromUnits: 2, toUnits: 1 }),
ev('speicher', '2026-10-07T10:00:00Z', { fromUnits: 0, toUnits: 1 }),
],
now
)
assert.deepEqual(open.map((o) => o.type), ['wechsel', 'speicher'])
assert.equal(openWithdrawals([ev('wechsel', '2026-10-05T10:00:00Z', {}, 'widerrufen')], now).length, 0)
// F02: ein Wechsel ohne Zahlung (gescheitert) hat wie ein widerrufener kein Widerrufsrecht mehr
assert.equal(openWithdrawals([ev('wechsel', '2026-10-05T10:00:00Z', {}, 'gescheitert')], now).length, 0)
})
// F04: die Frist zaehlt ab dem Vertragsschluss bei Stripe (details.contractAt), nicht ab dem fruehereren
// Portal-Klick (consentAt). Waere hier consentAt massgeblich, waere die Frist laengst abgelaufen.
test('Widerrufsfrist zaehlt ab dem Vertragsschluss (details.contractAt), nicht ab dem Portal-Klick', () => {
const now = new Date('2026-10-10T12:00:00Z')
const ev = {
$id: 'e1',
type: 'abschluss',
status: 'erledigt',
consentAt: '2026-08-01T10:00:00Z',
details: { contractAt: '2026-10-08T10:00:00Z' },
}
assert.deepEqual(openWithdrawals([ev], now).map((o) => o.eventId), ['e1'])
})
test('Aufgabe erledigen', async () => {
const [t] = await listOpenStorageTasks()
const done = await completeContractEvent(t.$id, 'kenso@webklar.com')
assert.equal(done.status, 'erledigt')
assert.equal(done.doneBy, 'kenso@webklar.com')
})
// F01: Status setzen und details zusammenfuehren (nicht ersetzen) in einem Aufruf
test('Ereignis aktualisieren: Status setzen, details zusammenfuehren', async () => {
const created = await recordContractEvent({
customerId: 'c2',
subscriptionId: 'sub_2',
type: 'wechsel',
productKey: 'hosting',
details: { von: 'wartung', zu: 'hosting' },
status: 'offen',
})
const updated = await updateContractEvent(created.$id, {
status: 'gescheitert',
details: { grund: 'Zahlung fehlgeschlagen' },
})
assert.equal(updated.status, 'gescheitert')
assert.deepEqual(JSON.parse(updated.details), { von: 'wartung', zu: 'hosting', grund: 'Zahlung fehlgeschlagen' })
})
// Task 9: ein einzelnes Ereignis lesen (markSwitchFailed, Skript fuers Team), details geparst wie in der Liste
test('Einzelnes Ereignis lesen: details geparst, unbekannte id 404', async () => {
const created = await recordContractEvent({
customerId: 'c5',
subscriptionId: 'sub_5',
type: 'wechsel',
productKey: 'wartung',
details: { from: 'hosting', paymentUrl: 'https://pay.example/inv' },
status: 'offen',
})
const event = await getContractEvent(created.$id)
assert.equal(event.$id, created.$id)
assert.equal(event.status, 'offen')
assert.deepEqual(event.details, { from: 'hosting', paymentUrl: 'https://pay.example/inv' })
await assert.rejects(getContractEvent('gibt-es-nicht'), (e) => e.status === 404)
})
// F05: dieselbe id zweimal speichern (z. B. Webhook-Wiederholung) ist kein Fehler, sondern liefert
// das schon vorhandene Dokument zurueck, ohne ein zweites anzulegen
test('Ereignis mit vorhandener id (409): liefert das schon gespeicherte Dokument', async () => {
const first = await recordContractEvent({
customerId: 'c3',
subscriptionId: 'sub_3',
type: 'abschluss',
productKey: 'hosting',
details: { contractAt: '2026-10-01T09:00:00.000Z' },
id: 'abschluss-sub_3',
})
const again = await recordContractEvent({
customerId: 'c3',
subscriptionId: 'sub_3',
type: 'abschluss',
productKey: 'hosting',
details: { contractAt: '2026-10-01T09:00:00.000Z' },
id: 'abschluss-sub_3',
})
assert.equal(again.$id, 'abschluss-sub_3')
assert.equal(again.$id, first.$id)
assert.equal((await listContractEvents('c3')).length, 1)
})
// Rechtsrunde 28.09.2026: eine Speicher-Verringerung hat kein Pflicht-Haekchen und damit keine
// Zustimmungsdaten - recordContractEvent muss mit consent: null funktionieren und darf dann keine
// consentIp/consentUserAgent speichern
test('Speicher-Verringerung ohne Zustimmung (consent: null) speichert keine Zustimmungsdaten', async () => {
const doc = await recordContractEvent({
customerId: 'c4',
subscriptionId: 'sub_4',
type: 'speicher',
productKey: 'hosting',
details: { fromUnits: 2, toUnits: 1 },
consent: null,
})
assert.equal(doc.consentIp ?? null, null)
assert.equal(doc.consentUserAgent ?? null, null)
assert.equal(doc.termsVersion ?? null, null)
})
// F05 (Fix 1): feste Ereignis-IDs muessen Appwrites Regeln fuer Dokument-IDs einhalten (hoechstens
// 36 Zeichen) - 'abschluss-' + eine echte Stripe-Abo-ID (sub_ + 24) waeren 38. Deshalb
// '<typ>-' + sha256 des Schluessels, auf 36 Zeichen gekuerzt; gleicher Schluessel -> gleiche ID.
test('eventIdFor: 36 Zeichen nach Appwrite-Regeln, deterministisch', () => {
const subId = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp'
const id = eventIdFor('abschluss', subId)
assert.equal(id.length, 36)
assert.match(id, /^abschluss-[0-9a-f]{26}$/)
assert.equal(id, eventIdFor('abschluss', subId))
assert.notEqual(id, eventIdFor('abschluss', 'sub_1SD6vPRxAbCdEfGhIjKlMnOq'))
// Laengerer Typ -> kuerzerer Hash, immer 36 Zeichen
assert.equal(eventIdFor('kuendigung', subId).length, 36)
assert.match(eventIdFor('kuendigung', subId), /^kuendigung-[0-9a-f]{25}$/)
assert.throws(() => eventIdFor('abschluss', ''), /Schl/)
assert.throws(() => eventIdFor('Abschluss!', subId), /Typ/)
})
test('recordContractEvent lehnt IDs ab, die Appwrite nicht annimmt', async () => {
const base = { customerId: 'c6', subscriptionId: 'sub_6', type: 'abschluss', productKey: 'hosting' }
await assert.rejects(
recordContractEvent({ ...base, id: 'abschluss-sub_1SD6vPRxAbCdEfGhIjKlMnOp' }),
(e) => e.status === 400 && /36/.test(e.message)
)
await assert.rejects(recordContractEvent({ ...base, id: '-abschluss' }), (e) => e.status === 400)
await assert.rejects(recordContractEvent({ ...base, id: 'abschluss/1' }), (e) => e.status === 400)
assert.equal((await listContractEvents('c6')).length, 0)
// Gueltige feste ID (aus eventIdFor) wird gespeichert
const ok = await recordContractEvent({ ...base, id: eventIdFor('abschluss', 'sub_1SD6vPRxAbCdEfGhIjKlMnOp') })
assert.equal(ok.$id.length, 36)
})