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