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 }) })