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' // Meldung beim Einschalten im Abo-Modell (P6 Task 6): K38 an den Kunden, T24 ans Team (aboFunktionenMeldung.js, Aufruf am Ende // von einschalten). Aufbau wie test/funktionEinschalten.test.js, dazu hourCredits und die Mail-Senke. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), featureConsents: schemaKeys('featureConsents'), oeffnungszeiten: schemaKeys('oeffnungszeiten'), zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), hourCredits: schemaKeys('hourCredits'), }, 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 { customers, hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') const { deleteDocument } = 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 { einschalten } = await import('../server/services/funktionEinschalten.js') const { meldeEingeschaltet, meldungDeps } = await import('../server/services/aboFunktionenMeldung.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 }) const E = (over = {}) => einschalten({ customerId: 'c1', projectId: 'p1', key: 'news', by: 'kunde', abrechnung: 'abo', now: JETZT, ...over }) const projekt = (id, customerId = 'c1') => aw.seed('websiteProjects', { $id: id, customerId, projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static' }) const kunde = (over = {}) => ({ doc: aw.docs('portalFeatures').get('f_p1_news'), ticketWoid: '100150', by: 'kunde', abrechnung: 'abo', ...over }) let mails const betreff = (start) => mails.sent.filter((m) => m.subject.startsWith(start)) const originalRebuild = zeitenDeps.rebuild beforeEach(async () => { for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects', 'contractEvents', 'hourCredits']) { 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']) projekt(id) aw.seed('customers', { ...customers.c1 }) config.zeiten.mode = 'pilot' config.zeiten.pilotEmails = ['*'] mails = mailSink() setMailTransportForTests(mails) zeitenDeps.rebuild = async () => ({ queued: true }) mock.method(console, 'error', () => {}) mock.method(console, 'warn', () => {}) mock.method(console, 'log', () => {}) }) afterEach(() => { mock.restoreAll() zeitenDeps.rebuild = originalRebuild }) test('Kunde, Wartung ohne Guthaben: K38 mit Konto-Satz an den Kunden, T24 mit Vorschuss ans Team', async () => { await E() // einschalten wie P5 Task 9: ruft die Meldung selbst const [k38] = betreff('Deine Funktion wird eingerichtet: News-Pinnwand'); assert.deepEqual(k38.to.map((a) => a.address), ['k@example.invalid']) assert.match(k38.text, /geht von deinem Stundenkonto ab/); assert.match(k38.text, /\(Ticket #\w+\)/) const [t24] = betreff('[Abo] Funktion eingeschaltet: News-Pinnwand'); assert.match(t24.text, /Guthaben 0 min, Vorschuss 0 min/) assert.equal(betreff('[Abo] Funktion eingeschaltet').length, 1) }) test('Guthaben reicht (Gutschrift 300 min): T24 ohne Vorschuss-Satz; Hosting-Abo: auch keiner', async () => { aw.seed('hourCredits', { $id: 'h1', customerId: 'c1', minutes: 300, grantedAt: '2026-10-01T00:00:00.000Z', expiresAt: '2099-01-01T00:00:00.000Z' }) await E(); assert.doesNotMatch(betreff('[Abo] Funktion eingeschaltet')[0].text, /reicht voraussichtlich nicht/) // ein Hosting-Abo hat kein Stundenkonto: auch ohne Guthaben kein Vorschuss-Satz await deleteDocument('hourCredits', 'h1'); await deleteDocument('subscriptions', 'sub_h'); aw.seed('subscriptions', subscriptionDoc('sub_h', hostingDoc)) mails.sent.length = 0 assert.deepEqual(await meldeEingeschaltet(kunde()), { k38: true, t24: true }) assert.doesNotMatch(betreff('[Abo] Funktion eingeschaltet')[0].text, /reicht voraussichtlich nicht/) }) test('Terminplaner: K38 mit zwei PDF-Anhängen; PDFs nicht lesbar: K38 ohne Anhang, T24 mit Hinweis', async () => { await E({ key: 'termine' }); const k = betreff('Deine Funktion wird eingerichtet')[0] assert.deepEqual(k.attachments.map((a) => a.contentType), ['application/pdf', 'application/pdf']); assert.match(k.text, /hängen als PDF an/) mails.sent.length = 0; const echt = meldungDeps.anhaenge; meldungDeps.anhaenge = async () => { throw new Error('weg') } try { await E({ key: 'termine', projectId: 'p2' }) } finally { meldungDeps.anhaenge = echt } assert.equal(betreff('Deine Funktion wird eingerichtet')[0].attachments.length, 0); assert.match(betreff('[Abo]')[0].text, /von Hand nachsenden/) }) test('Team im Projekt: K38 mit Projektkosten-Satz, kein Konto-Satz, kein T24; Zeiten: nur T24 (K27 bleibt)', async () => { await E({ by: 'team', abrechnung: 'projekt', staff: { name: 'Andrej' }, nachweis: null }) const k = betreff('Deine Funktion wird eingerichtet')[0]; assert.match(k.text, /Von deinem Stundenkonto geht nichts ab/); assert.match(k.text, /Die Funktion läuft, solange dein Wartungs-Abo läuft/); assert.doesNotMatch(k.text, /geht von deinem Stundenkonto ab/); assert.equal(betreff('[Abo]').length, 0) mails.sent.length = 0; await E({ key: 'zeiten', projectId: 'p2' }) assert.deepEqual([betreff('Deine Funktion wird eingerichtet').length, betreff('[Abo] Funktion eingeschaltet').length], [0, 1]) }) test('Wiederholung und Heilung: höchstens eine Meldung je Einschalten; Versandfehler und Kunde ohne Adresse brechen nichts ab', async () => { await E(); await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet'); assert.equal(betreff('Deine Funktion').length, 1) mails.sent.length = 0; aw.failNext({ collection: 'workorders', method: 'POST', status: 500 }) await assert.rejects(E({ projectId: 'p2' }), (e) => e.status === 503); assert.equal(mails.sent.length, 0) // Ticket scheitert: noch keine Mail await E({ projectId: 'p2' }); assert.equal(betreff('Deine Funktion').length, 1) // Heilung: genau eine setMailTransportForTests({ sendMail: async () => { throw new Error('smtp') } }) const r = await meldeEingeschaltet(kunde()); assert.deepEqual(r, { k38: false, t24: false }) setMailTransportForTests(mails); aw.seed('customers', { ...customers.c1, email: '' }); assert.equal((await meldeEingeschaltet(kunde())).k38, false) })