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

219 lines
13 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('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 })
})