import { test, after, beforeEach } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { mailSink } from './support/mailSink.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' // Buchungskalender Task 20: Mails an Gaeste (ueber die Warteschlange), Vermieter und Team const NAMEN = ['customers', 'ukUnterkuenfte', 'ukPreise', 'ukAnfragen', 'ukRechnungen', 'ukEinstellungen', 'termineOutbox'] const aw = await startAppwriteMock({ schema: Object.fromEntries(NAMEN.map((n) => [n, n === 'customers' ? ['name', 'email'] : schemaKeys(n)])), datetimes: { ...TERMINE_DATETIMES, ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt'] }, defaultLimit: 25, }) after(() => aw.close()) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' }) const { config } = await import('../server/config.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { warteAufVersand, resetOutboxForTests } = await import('../server/services/termine/outbox.js') const mails = await import('../server/services/unterkunft/mails.js') const sp = await import('../server/services/unterkunft/speicher.js') after(() => setMailTransportForTests(null)) const EIN = { firma: 'Haus am See', strasse: 'Weg 1', plz: '10115', ort: 'Berlin', email: 'vermieter@example.com', telefon: '0123', akzentfarbe: '#336699' } const ctx = { featureId: 'f1', customerId: 'c1', projectId: 'p1', siteKey: 'abcdefghijklmnop', besitzerEmail: 'besitzer@example.com', einstellungen: EIN } const POSTEN = [{ text: 'Übernachtung, 2 Nächte', menge: 2, einzelCent: 10000, ustProzent: 7 }, { text: 'Endreinigung', menge: 1, einzelCent: 5000, ustProzent: 19 }] const anfrage = (extra = {}) => ({ $id: 'a1', featureId: 'f1', unterkunftId: 'u1', nummer: 'A-K7QM', von: '2026-11-12', bis: '2026-11-14', personen: 2, status: 'freigegeben', gast: { name: 'Eva Gast', email: 'eva@example.com', nachricht: 'Hallo' }, posten: POSTEN, summen: { bruttoCent: 25000 }, fristAt: '2026-11-05T16:00:00.000Z', stornobedingungenText: 'Kostenlos bis 7 Tage vorher.', nachrichtVermieter: '', ...extra, }) let sink beforeEach(async () => { for (const c of ['termineOutbox', 'ukRechnungen', 'ukUnterkuenfte', 'ukEinstellungen']) for (const id of aw.docs(c).keys()) await deleteDocument(c, id) config.smtp.from = 'noreply@webklar.com' resetOutboxForTests() mails._aufrufe.length = 0 sink = mailSink() setMailTransportForTests(sink) aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: 'f1', name: 'Wohnung See', maxPersonen: 4, mindestNaechte: 1, aktiv: true }) }) const gesendet = async () => { await warteAufVersand() return sink.sent } test('G17: Absender = Firma, Antworten an den Vermieter, Buchungslink mit Token, Preis und Frist', async () => { await mails.anGast('G17', ctx, anfrage(), { token: 'tok123' }) const [m] = await gesendet() assert.equal(m.from.name, 'Haus am See') assert.equal(m.replyTo[0].address, 'vermieter@example.com') assert.equal(m.to[0].address, 'eva@example.com') assert.ok(m.text.includes(`${config.publicBaseUrl}/unterkunft/abcdefghijklmnop/buchung/a1?t=tok123`)) assert.ok(m.text.includes('250,00 €')) assert.ok(m.text.includes('12.11.2026 – 14.11.2026')) assert.ok(m.text.includes('Kostenlos bis 7 Tage vorher.')) assert.ok(m.text.includes('05.11.2026, 17:00 Uhr')) assert.ok(m.text.includes('Erst mit dem Klick auf „Zahlungspflichtig buchen“')) assert.deepEqual(mails._aufrufe[0], { an: 'gast', art: 'G17', anfrageId: 'a1', hatToken: true, grund: undefined }) assert.ok(!JSON.stringify(mails._aufrufe).includes('tok123')) }) test('Minimaler ctx: Einstellungen und Unterkunft werden selbst geladen', async () => { aw.seed('ukEinstellungen', { $id: 'f1', featureId: 'f1', ...EIN, firma: 'Geladene Firma' }) await mails.anGast('G21', { featureId: 'f1' }, anfrage()) const [m] = await gesendet() assert.equal(m.from.name, 'Geladene Firma') assert.ok(m.text.includes('Wohnung See')) assert.doesNotMatch(m.text, /buchung\/a1/) }) test('G17 ohne Token oder ohne siteKey wirft vor dem Einreihen, G16 hat keinen Link', async () => { await assert.rejects(() => mails.anGast('G17', ctx, anfrage(), {}), TypeError) await assert.rejects(() => mails.anGast('G17', { ...ctx, siteKey: '' }, anfrage(), { token: 't' }), TypeError) assert.equal(aw.docs('termineOutbox').size, 0) await mails.anGast('G16', ctx, anfrage({ status: 'neu', posten: null, summen: null })) const [m] = await gesendet() assert.doesNotMatch(m.text, /\?t=/) }) test('G19 hängt die Rechnung als PDF an (online, unbezahlt: Bezahl-Hinweis), G22 die Stornorechnung mit Erstattung', async () => { const inhalt = { aussteller: { firma: 'Haus am See' }, empfaenger: { name: 'Eva Gast' }, unterkunft: { name: 'Wohnung See' }, von: '2026-11-12', bis: '2026-11-14', naechte: 2, personen: 2, posten: POSTEN, summen: { bruttoCent: 25000, nettoCent: 23000, ust: [] }, steuerart: 'kleinunternehmer', zahlart: 'online' } const re = await sp.legeRechnungAn({ featureId: 'f1', anfrageId: 'a1', art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01', bruttoCent: 25000, inhalt }) const st = await sp.legeRechnungAn({ featureId: 'f1', anfrageId: 'a1', art: 'storno', nummer: 'RE-2026-0002', datum: '2026-11-02', bruttoCent: -25000, inhalt: { ...inhalt, bezugNummer: 'RE-2026-0001' } }) await mails.anGast('G19', ctx, anfrage({ status: 'gebucht', zahlart: 'online' }), { rechnung: re }) await mails.anGast('G22', ctx, anfrage({ status: 'storniert' }), { stornoRechnung: st, erstattetCent: 25000, grund: 'Wasserschaden' }) const sent = await gesendet() const g19 = sent.find((m) => m.subject.includes('bestätigt')) const g22 = sent.find((m) => m.subject.includes('storniert')) assert.equal(g19.attachments.length, 1) assert.equal(g19.attachments[0].filename, 'Rechnung-RE-2026-0001.pdf') assert.ok(Buffer.from(g19.attachments[0].content, 'base64').subarray(0, 4).toString() === '%PDF') assert.ok(g19.text.includes('Den Bezahl-Link finden Sie auf der Buchungsseite')) assert.equal(g22.attachments[0].filename, 'Stornorechnung-RE-2026-0002.pdf') assert.ok(g22.text.includes('Eine Erstattung von 250,00 € wird veranlasst.')) assert.ok(g22.text.includes('Wasserschaden')) }) test('G22 ohne Erstattung: kein Erstattungssatz; G19 vor Ort bezahlt: kein Bezahl-Hinweis', async () => { await mails.anGast('G22', ctx, anfrage({ status: 'storniert' }), { erstattetCent: 0 }) await mails.anGast('G19', ctx, anfrage({ status: 'gebucht', zahlart: 'vorort' }), {}) const sent = await gesendet() assert.ok(sent.every((m) => !m.text.includes('Erstattung von') && !m.text.includes('Bezahl-Link'))) }) test('Vermieter: K36 geht an den Besitzer ohne BCC, mit Portal-Link; T23 ans Team; unbekannte Gastart wirft', async () => { await mails.anVermieter('K36', ctx, { anfrage: anfrage({ status: 'neu' }) }) const [k] = await gesendet() assert.equal(k.to[0].address, 'besitzer@example.com') assert.ok(!k.bcc || k.bcc.length === 0) assert.ok(k.text.includes(`${config.publicBaseUrl}/dashboard.html#buchungen?projekt=p1`)) assert.ok(k.text.includes('Eva Gast')) await assert.rejects(() => mails.anGast('K36', ctx, anfrage()), TypeError) }) test('Gastname wird in der Mail maskiert', async () => { await mails.anGast('G16', ctx, anfrage({ gast: { name: 'x', email: 'eva@example.com' } })) const [m] = await gesendet() assert.ok(!m.html.includes('x')) assert.ok(m.html.includes('<b>x</b>')) }) test('K36: Kundenname kommt aus dem Kundendokument, wenn ctx keinen hat', async () => { aw.seed('customers', { $id: 'c1', name: 'Mimi Muster', email: 'besitzer@example.com' }) await mails.anVermieter('K36', ctx, { anfrage: anfrage({ status: 'neu' }) }) const [k] = await gesendet() assert.ok(k.text.includes('Hallo Mimi Muster')) })