301 lines
17 KiB
JavaScript
301 lines
17 KiB
JavaScript
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'
|
|
|
|
// POST /api/funktionen/:key/einschalten (V5): nur in der neuen Ansicht, Pruefreihenfolge, Nachweis mit Kundenart, Weiche an /zeiten/einschalten
|
|
const aw = await startAppwriteMock({
|
|
schema: {
|
|
portalFeatures: schemaKeys('portalFeatures'),
|
|
subscriptions: schemaKeys('subscriptions'),
|
|
contractEvents: schemaKeys('contractEvents'),
|
|
featureConsents: schemaKeys('featureConsents'),
|
|
shopConfig: schemaKeys('shopConfig'),
|
|
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 { startTestApp, post, sessionCookie } = await import('./support/testApp.js')
|
|
const { customerCookie, customers, hostingDoc, kundenansichtCookie, 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 { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
|
|
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
|
|
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
|
|
const { einschaltenDeps } = await import('../server/services/zeiten/einschalten.js')
|
|
const { consentAbo } = await import('../server/services/featureRegistry.js')
|
|
const { EINSCHALTEN_TEXTE } = await import('../server/services/funktionEinschalten.js')
|
|
|
|
const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
|
|
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
|
after(() => setMailTransportForTests(null))
|
|
|
|
const JETZT = new Date('2026-10-17T08:00:00.000Z') // ein Samstag
|
|
const c1 = customerCookie('c1')
|
|
const vorher = {
|
|
neu: { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] },
|
|
termine: config.termine.mode,
|
|
shop: config.shop.mode,
|
|
zeiten: { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] },
|
|
unterlagen: Object.fromEntries(['news', 'termine', 'shop', 'unterkunft', 'zeiten'].map((k) => [k, config.features[k].unterlagenBereit])),
|
|
}
|
|
after(() => {
|
|
Object.assign(config.kundenansichtNeu, vorher.neu)
|
|
config.termine.mode = vorher.termine
|
|
config.shop.mode = vorher.shop
|
|
Object.assign(config.zeiten, vorher.zeiten)
|
|
for (const [k, fn] of Object.entries(vorher.unterlagen)) {
|
|
if (fn === undefined) delete config.features[k].unterlagenBereit
|
|
else config.features[k].unterlagenBereit = fn
|
|
}
|
|
})
|
|
|
|
const Z = (key, haken = []) => ({ version: consentAbo(key)?.version ?? 'unbekannt', haken }) // consentAbo aus featureRegistry.js (null für analyse und unbekannte Schlüssel)
|
|
const go = (key, body = {}, cookie = c1) => post(`/api/funktionen/${key}/einschalten`, { projectId: 'p1', zustimmung: Z(key), ...body }, { cookie })
|
|
const abschluss = (contractAt = JETZT.toISOString()) =>
|
|
aw.seed('contractEvents', { $id: 'ev_a', customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }) })
|
|
const nichts = () => assert.deepEqual([aw.docs('portalFeatures').size, aw.docs('workorders').size, aw.docs('featureConsents').size], [0, 0, 0])
|
|
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht und geaendert
|
|
const leere = async (...collections) => {
|
|
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
|
|
}
|
|
const projekt = (id, over = {}) => aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over })
|
|
|
|
let sink
|
|
const originalRebuild = zeitenDeps.rebuild
|
|
beforeEach(async () => {
|
|
await leere('subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects', 'contractEvents', 'shopConfig')
|
|
resetZeitenCacheForTests()
|
|
seedFixtures(aw)
|
|
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
|
|
for (const id of ['p1', 'p1b', 'p1c']) projekt(id)
|
|
projekt('p_archiv', { archived: true })
|
|
projekt('p_fremd', { customerId: 'c2' })
|
|
config.kundenansichtNeu.mode = 'pilot'
|
|
config.kundenansichtNeu.pilotEmails = ['k@example.invalid', 'test@example.invalid']
|
|
config.termine.mode = config.shop.mode = 'on'
|
|
config.zeiten.mode = 'pilot'
|
|
config.zeiten.pilotEmails = ['*']
|
|
for (const k of ['news', 'termine', 'shop', 'unterkunft', 'zeiten']) config.features[k].unterlagenBereit = async () => true
|
|
einschaltenDeps.jetzt = () => JETZT
|
|
sink = mailSink()
|
|
setMailTransportForTests(sink)
|
|
zeitenDeps.rebuild = async () => ({ queued: true })
|
|
mock.method(console, 'error', () => {})
|
|
mock.method(console, 'warn', () => {})
|
|
mock.method(console, 'log', () => {})
|
|
})
|
|
afterEach(() => {
|
|
mock.restoreAll()
|
|
zeitenDeps.rebuild = originalRebuild
|
|
einschaltenDeps.jetzt = () => new Date()
|
|
})
|
|
|
|
test('Erfolg (Betrieb, Wartung): Form, Zustand, Ticket, Nachweis mit Kundenart, K38 und T24 (P6) ohne Preis, kein Stripe', async () => {
|
|
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
|
|
const r = await go('news')
|
|
assert.deepEqual([r.status, Object.keys(r.body).sort(), r.body.feature.state, r.body.feature.unlockedBy], [200, ['feature', 'ticketWoid'], 'setup', 'abo'])
|
|
assert.equal(r.body.ticketWoid, r.body.feature.ticketWoid)
|
|
assert.equal(aw.docs('workorders').size, 1)
|
|
// P6: das Einschalten meldet K38 an den Kunden und T24 ans Team (aboFunktionenMeldung.js); kein Preis, kein Stripe
|
|
assert.deepEqual([sink.sent.map((m) => m.subject.split(':')[0]), st.calls.length], [['Deine Funktion wird eingerichtet', '[Abo] Funktion eingeschaltet'], 0])
|
|
assert.doesNotMatch(JSON.stringify(sink.sent), /€/)
|
|
const n = [...aw.docs('featureConsents').values()][0]
|
|
assert.deepEqual([JSON.parse(n.texte).map((t) => t.id), n.kundenart, n.stripeSessionId], [['einschalten-zeile', 'knopf'], 'unternehmen', ''])
|
|
assert.doesNotMatch(JSON.stringify(r.body), /€/)
|
|
assert.equal(n.acceptedAt.slice(0, 19), '2026-10-17T08:00:00', 'Zeit des Klicks aus der Uhr der Route')
|
|
})
|
|
|
|
test('Einschalten nur in neuer Ansicht (Pilot-Ränder)', async () => {
|
|
const faelle = [['Kunde nicht in der Liste', 'pilot', ['k2@example.invalid']], ['off schlägt *', 'off', ['*']], ['Pilot mit leerer Liste', 'pilot', []]]
|
|
for (const [name, mode, liste] of faelle) {
|
|
config.kundenansichtNeu.mode = mode
|
|
config.kundenansichtNeu.pilotEmails = liste
|
|
assert.equal((await go('news')).status, 404, name)
|
|
nichts()
|
|
}
|
|
config.kundenansichtNeu.mode = 'pilot'
|
|
config.kundenansichtNeu.pilotEmails = ['*']
|
|
assert.equal((await go('news', { projectId: 'p1b' })).status, 200) // * lässt jeden Kunden durch
|
|
config.kundenansichtNeu.pilotEmails = ['test@example.invalid'] // Session-E-Mail mit Großbuchstaben und Leerzeichen trifft die Liste (nicht 404)
|
|
const schreibweise = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', name: 'Kunde Eins', email: ' TEST@Example.INVALID ' })
|
|
assert.notEqual((await go('news', { projectId: 'p1c' }, schreibweise)).status, 404)
|
|
assert.equal((await go('news', {}, kundenansichtCookie({ customerId: 'c1' }))).status, 403) // Mitarbeiter-Kundenansicht im Pilot: gesperrt, nicht 404
|
|
assert.equal((await post('/api/funktionen/news/einschalten', {})).status, 401)
|
|
for (const key of ['analyse', 'foo', '__proto__']) assert.equal((await go(key)).status, 404, key)
|
|
})
|
|
|
|
test('Weiche an /zeiten/einschalten: alte Ansicht alter Weg, neue Ansicht neuer Weg', async () => {
|
|
config.kundenansichtNeu.mode = 'off'
|
|
const alt = await post('/api/funktionen/zeiten/einschalten', { projectId: 'p1', zustimmung: { version: config.features.zeiten.consentFrei.version, haken: config.features.zeiten.consentFrei.items.filter((i) => i.haken).map((i) => i.id) } }, { cookie: c1 })
|
|
assert.deepEqual([alt.status, Object.keys(alt.body).sort()], [200, ['einbau', 'feature']])
|
|
await leere('portalFeatures')
|
|
config.kundenansichtNeu.mode = 'pilot'
|
|
const neu = await go('zeiten')
|
|
assert.deepEqual([neu.status, Object.keys(neu.body).sort()], [200, ['feature', 'ticketWoid']])
|
|
})
|
|
|
|
test('Kundenart (leer, unbekannt, gefälscht) und Frist: beginn_noetig mit ab, Häkchen, Betrieb ohne Häkchen', async () => {
|
|
abschluss() // Samstag 17.10. + 14 Tage -> Frist bis Mo 02.11., ab = Di 03.11. 00:00 Berlin
|
|
for (const kundenart of [undefined, '', 'kaputt', 'Unternehmen']) {
|
|
aw.seed('customers', { ...customers.c1, ...(kundenart === undefined ? {} : { kundenart }) })
|
|
const r = await go('news', { kundenart: 'unternehmen', unternehmer: true })
|
|
assert.deepEqual([r.status, r.body.code, r.body.ab], [409, 'beginn_noetig', '2026-11-02T23:00:00.000Z'], String(kundenart))
|
|
assert.match(r.body.error, /03\.11\.2026/)
|
|
nichts()
|
|
}
|
|
assert.equal((await go('news', { zustimmung: Z('news', ['beginn']) })).status, 200)
|
|
const p = JSON.parse([...aw.docs('featureConsents').values()][0].texte)
|
|
assert.deepEqual(p.map((t) => [t.id, t.haken]), [['einschalten-zeile', false], ['beginn', true], ['knopf', false]])
|
|
await leere('portalFeatures', 'workorders', 'featureConsents')
|
|
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
|
|
assert.equal((await go('news', { zustimmung: Z('news', ['beginn']) })).status, 200) // Betrieb: beginn wird ignoriert
|
|
assert.deepEqual(JSON.parse([...aw.docs('featureConsents').values()][0].texte).map((t) => t.id), ['einschalten-zeile', 'knopf'])
|
|
await leere('portalFeatures')
|
|
aw.seed('customers', { ...customers.c1 })
|
|
abschluss('2026-09-01T09:00:00.000Z') // privat, Frist vorbei: kein Häkchen nötig
|
|
assert.equal((await go('news')).status, 200)
|
|
})
|
|
|
|
test('Prüfreihenfolge der 409-Codes', async () => {
|
|
config.termine.mode = 'off'
|
|
await leere('subscriptions')
|
|
assert.equal((await go('termine')).body.code, 'nicht_verfuegbar')
|
|
config.termine.mode = 'on'
|
|
assert.equal((await go('termine')).body.code, 'abo_fehlt') // vor nur_betrieb
|
|
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
|
|
assert.equal((await go('shop')).body.code, 'nur_betrieb') // privat, vor bestellung_offen
|
|
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
|
|
const o = await go('shop')
|
|
assert.deepEqual([o.body.code, o.body.offen], ['bestellung_offen', ['Deine Webshop-Anfrage ist noch nicht freigegeben.']])
|
|
assert.equal((await go('news')).status, 200)
|
|
assert.equal((await go('news', { zustimmung: { version: 'alt', haken: [] } })).body.code, 'bereits_eingeschaltet') // vor fassung_veraltet
|
|
const alt = await go('zeiten', { zustimmung: { version: 'alt', haken: [] } })
|
|
assert.deepEqual([alt.status, alt.body.code, alt.body.consent.version], [409, 'fassung_veraltet', config.aboFunktionen.fassung])
|
|
assert.equal(alt.body.error, 'Die Erklärung wurde inzwischen geändert. Bitte lies sie noch einmal und schalte dann ein.')
|
|
})
|
|
|
|
test('Hinweis (Pflichten der alten Häkchen): der Satz der Funktion steht im Nachweis; news hat keinen', async () => {
|
|
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
|
|
const alt = ['termine', 'shop'].map((k) => config.features[k].bestellpruefung)
|
|
for (const k of ['termine', 'shop']) config.features[k].bestellpruefung = async () => [] // Fragebogen und Freigabe sind hier nicht das Thema
|
|
try {
|
|
for (const key of ['termine', 'shop']) {
|
|
const r = await go(key)
|
|
assert.equal(r.status, 200, `${key} ${JSON.stringify(r.body)}`)
|
|
const t = JSON.parse([...aw.docs('featureConsents').values()].at(-1).texte)
|
|
assert.deepEqual(t.map((x) => x.id), ['einschalten-zeile', 'hinweis', 'knopf'], key)
|
|
assert.equal(t[1].text, consentAbo(key).items[1].text, key)
|
|
assert.equal(t[1].haken, false)
|
|
}
|
|
} finally {
|
|
['termine', 'shop'].forEach((k, i) => {
|
|
if (alt[i] === undefined) delete config.features[k].bestellpruefung
|
|
else config.features[k].bestellpruefung = alt[i]
|
|
})
|
|
}
|
|
assert.match(consentAbo('shop').items[1].text, /eigenem Namen.*eigenes Stripe-Konto/)
|
|
assert.match(consentAbo('termine').items[1].text, /Gesundheitsbereich/)
|
|
await leere('featureConsents')
|
|
assert.equal((await go('news')).status, 200)
|
|
assert.deepEqual(JSON.parse([...aw.docs('featureConsents').values()][0].texte).map((x) => x.id), ['einschalten-zeile', 'knopf'])
|
|
})
|
|
|
|
test('Probezeit (Privatkunde bestellte das Abo ohne Häkchen): abo_fehlt beginnt_spaeter mit Datum statt Zahlungsmeldung', async () => {
|
|
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-11-03T05:00:00.000Z' }))
|
|
const r = await go('news')
|
|
assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel, r.body.ab], [409, 'abo_fehlt', 'beginnt_spaeter', false, '2026-11-03T05:00:00.000Z'])
|
|
assert.equal(r.body.error, 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.')
|
|
assert.doesNotMatch(r.body.error, /Zahlung/)
|
|
nichts()
|
|
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-10-01T05:00:00.000Z' })) // Probezeit vorbei, Stripe noch nicht nachgezogen
|
|
assert.equal((await go('news')).body.grund, 'nicht_aktiv')
|
|
})
|
|
|
|
test('Doppelt und widersprüchlich: nacheinander, gleichzeitig, zweite Website, Zustände', async () => {
|
|
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
|
|
assert.deepEqual([(await go('news')).status, (await go('news')).body.code], [200, 'bereits_eingeschaltet'])
|
|
assert.deepEqual([aw.docs('workorders').size, aw.docs('featureConsents').size], [1, 1])
|
|
assert.equal((await go('news', { projectId: 'p1b' })).status, 200)
|
|
assert.equal(aw.docs('workorders').size, 2) // zweite Website, eigenes Ticket
|
|
const gl = await Promise.all([go('news', { projectId: 'p1c' }), go('news', { projectId: 'p1c' })])
|
|
assert.deepEqual(gl.map((x) => x.status).sort(), [200, 409])
|
|
assert.equal(aw.docs('workorders').size, 3)
|
|
await updateDocument('portalFeatures', 'f_p1_news', { state: 'active' })
|
|
assert.equal((await go('news')).body.code, 'bereits_eingeschaltet')
|
|
await updateDocument('portalFeatures', 'f_p1_news', { state: 'ordered' })
|
|
assert.equal((await go('news')).body.code, 'bestellung_laeuft')
|
|
await updateDocument('portalFeatures', 'f_p1_news', { state: 'disabled' })
|
|
assert.equal((await go('news')).body.code, 'abgeschaltet')
|
|
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'setup', unlockedBy: 'abo', unlockedByPurchase: false })
|
|
assert.equal((await go('news')).status, 200) // setup ohne Ticket: Heilung, kein 409
|
|
})
|
|
|
|
test('Abo-Grenzfälle', async () => {
|
|
const faelle = [
|
|
['Hosting + Wartungs-Funktion', hostingDoc, 'falsches_abo', true],
|
|
['gekündigt', { ...wartungDoc, cancelAtPeriodEnd: true }, 'gekuendigt', false],
|
|
['past_due', { ...wartungDoc, status: 'past_due' }, 'nicht_aktiv', false],
|
|
['beendet', { ...wartungDoc, status: 'canceled' }, 'kein_abo', false],
|
|
]
|
|
for (const [name, doc, grund, wechsel] of faelle) {
|
|
aw.seed('subscriptions', subscriptionDoc('sub_h', doc))
|
|
const r = await go('termine')
|
|
assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel], [409, 'abo_fehlt', grund, wechsel], name)
|
|
}
|
|
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
|
|
config.features.news.bestellpruefung = async () => {
|
|
await leere('subscriptions') // Abo endet nach der Vorprüfung, vor der Sperre
|
|
return []
|
|
}
|
|
try {
|
|
const spaet = await go('news')
|
|
assert.deepEqual([spaet.status, spaet.body.code], [409, 'abo_fehlt'])
|
|
nichts()
|
|
} finally {
|
|
delete config.features.news.bestellpruefung
|
|
}
|
|
})
|
|
|
|
test('Abo nicht lesbar: 503 mit Text, nichts geschrieben', async () => {
|
|
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
|
|
const r = await go('news')
|
|
assert.deepEqual([r.status, r.body.error], [503, EINSCHALTEN_TEXTE.nichtPruefbar])
|
|
nichts()
|
|
})
|
|
|
|
test('Eingabe und Archiv', async () => {
|
|
const faelle = [
|
|
[{ projectId: '' }, 400],
|
|
[{ zustimmung: undefined }, 400],
|
|
[{ zustimmung: { version: 'x', haken: 'beginn' } }, 400],
|
|
[{ zustimmung: { version: 7, haken: [] } }, 400],
|
|
[{ zustimmung: Z('news', ['x']) }, 400],
|
|
[{ projectId: 'p_fremd' }, 403],
|
|
[{ projectId: 'p_archiv' }, 409],
|
|
]
|
|
for (const [body, status] of faelle) assert.equal((await go('news', body)).status, status, JSON.stringify(body))
|
|
assert.equal((await go('news', { zustimmung: undefined })).body.code, 'zustimmung_fehlt')
|
|
nichts()
|
|
})
|
|
|
|
test('Unterlagen nicht abrufbar: 503 unterlagen_fehlen vor dem Abo', async () => {
|
|
config.features.news.unterlagenBereit = async () => false
|
|
const r = await go('news')
|
|
assert.deepEqual([r.status, r.body.code], [503, 'unterlagen_fehlen'])
|
|
nichts()
|
|
})
|