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>
238 lines
15 KiB
JavaScript
238 lines
15 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'
|
||
|
||
// 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 })
|
||
})
|