Files
Webklar-Kundenbereich/test/funktionEinschalten.test.js
T
ClaudeandClaude Sonnet 5.5 3e32ddfe86 Kundenansicht neu E-Fix M8: Wechselangebot beim Einschalten nur, wenn der Wechsel möglich ist
aboDeckt nennt falsches_abo vor gekuendigt, beginnt_spaeter und nicht_aktiv (die Reihenfolge bleibt gepinnt). Die 409-Antwort setzte wechsel: true
allein aus grund === 'falsches_abo': ein Hosting-Abo in der Probezeit, mit offener Zahlung oder vorgemerkter Kündigung bekam das Angebot, landete auf
#wechsel bei "Ein Wechsel ist derzeit nicht möglich" und verlor den eigentlichen Grund. Jetzt prüft pruefeAboDeckung bei falsches_abo zusätzlich
wechselMoeglich (aboFunktionen.js, dieselben Bedingungen wie canSwitch und assertCanSwitch: Hosting, aktiv, nicht gekündigt, kein offener Wechsel);
ein Lesefehler dabei nimmt nur das Angebot weg. Test in funktionEinschalten.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:47:36 +00:00

238 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('M8: das Wechselangebot (wechsel true) gibt es nur, wenn der Wechsel auch möglich ist (Hosting, aktiv, nicht gekündigt, kein offener Wechsel)', async () => {
const ohneWechsel = async (doc, name) => {
await loescheAbos(); aw.seed('subscriptions', subscriptionDoc('sub_h', doc))
await assert.rejects(E(), (e) => e.status === 409 && e.body.grund === 'falsches_abo' && e.body.wechsel === false && e.message === EINSCHALTEN_TEXTE.abo.falsches_abo, name)
}
await ohneWechsel({ ...hostingDoc, cancelAtPeriodEnd: true }, 'Hosting zum Periodenende gekündigt')
await ohneWechsel({ ...hostingDoc, cancelAt: '2026-12-01T00:00:00.000Z' }, 'Hosting mit cancelAt')
await ohneWechsel({ ...hostingDoc, status: 'past_due' }, 'Hosting past_due')
await ohneWechsel({ ...hostingDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-11-03T05:00:00.000Z' }, 'Hosting in der Probezeit')
// offener Wechsel (Zahlung steht aus): das Angebot käme zu #wechsel, dort ist nichts mehr zu bestellen
await loescheAbos(); aw.seed('subscriptions', subscriptionDoc('sub_h', hostingDoc))
aw.seed('contractEvents', { $id: 'ev_wechsel_offen', customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', status: 'offen', details: '{}', termsVersion: 'x', consentAt: '2026-10-16T08:00:00.000Z' })
await assert.rejects(E(), (e) => e.body.grund === 'falsches_abo' && e.body.wechsel === false, 'offener Wechsel')
await deleteDocument('contractEvents', 'ev_wechsel_offen')
// Hosting aktiv und nicht gekündigt: das Angebot bleibt
await assert.rejects(E(), (e) => e.body.grund === 'falsches_abo' && e.body.wechsel === true, 'Hosting aktiv')
assert.deepEqual(spuren(), { docs: 0, tickets: 0 })
})
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 })
})