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 { useTestEnv } from './support/testEnv.js' import { mailSink } from './support/mailSink.js' import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' // Widerruf eines Projektauftrags über /api/abo/public/withdraw (order = 'projekt', abNummer optional); K04 und T09 mit dem Block projekt process.env.AUFTRAEGE_ENABLED = 'on' const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, post, send } = await import('./support/testApp.js') const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js') const { withdrawalDeadline } = await import('../server/services/deadlines.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { default: aboRoutes, resetPublicRateLimit } = await import('../server/routes/abo.js') const { default: tokenRoutes, resetAuftragRoutesForTests } = await import('../server/routes/auftrag.js') const app = await startTestApp({ '/api/abo': aboRoutes, '/api/auftrag': tokenRoutes }) const sink = mailSink() setMailTransportForTests(sink) after(() => { setMailTransportForTests(null) return Promise.all([app.close(), aw.close(), st.close()]) }) const vor = (tage) => new Date(Date.now() - tage * 86_400_000).toISOString() const tag = (zeit) => new Date(zeit).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) }) let protokoll beforeEach(() => { resetPublicRateLimit() resetAuftragRoutesForTests() sink.sent.length = 0 config.auftraege.mode = 'on' protokoll = mock.method(console, 'error', () => {}) }) afterEach(() => mock.restoreAll()) const widerruf = (body = {}) => post('/api/abo/public/withdraw', { name: 'Erika Muster', email: 'k@example.invalid', order: 'projekt', abNummer: 'AB-2026-0001', ...body }) const k04 = () => sink.sent.find((mail) => mail.subject.startsWith('Eingangsbestätigung Ihres Widerrufs')) const t09 = () => sink.sent.find((mail) => mail.subject.startsWith('[Abo] WIDERRUF eingegangen')) const zeile = (mail, anfang) => mail.text.split('\n').find((z) => z.startsWith(anfang)) const LABEL = 'Projektauftrag (Auftragsbestätigung AB-2026-0001)' const OHNE_NUMMER = 'Projektauftrag (Auftragsbestätigung)' const UNTERNEHMEN_HINWEIS = 'Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.' const KEINE_NUMMER = 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen' const pruefeT09 = (ergebnis, frist, eingang) => { assert.equal(zeile(t09(), 'Ergebnis: '), `Ergebnis: ${ergebnis}`) assert.equal(zeile(t09(), 'Widerrufsfrist bis: '), `Widerrufsfrist bis: ${frist}`) assert.equal(zeile(t09(), 'Eingang: '), `Eingang: ${eingang}`) } test('Privatperson innerhalb der Frist: AB widerrufen, Antwort wie immer, K04 und T09 mit Block projekt; kein Stripe', async () => { const gesendetAm = vor(3) seed({ gesendetAm }) const stripeVorher = st.calls.length const res = await widerruf({ customerNumber: 'KD-9' }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt']) assert.equal(st.calls.length, stripeVorher, 'kein Stripe-Aufruf') const ab = await getAuftrag('ab1') assert.equal(ab.status, 'widerrufen') assert.equal(new Date(ab.widerrufenAm).getTime(), new Date(res.body.receivedAt).getTime()) // K04 an die Privatperson: Vertragsart, „wir prüfen“, keine Zusagen, kein Hinweis für Unternehmen assert.equal(k04().subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag') assert.deepEqual(k04().to.map((a) => a.address), ['k@example.invalid']) assert.ok(k04().text.includes(`Widerruf der Bestellung „${LABEL}“ am `), k04().text) assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${LABEL}`) assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.')) assert.ok(k04().text.includes('Kundennummer: KD-9')) assert.doesNotMatch(k04().text, /Unternehmen|Abbuchungen|Wir prüfen, ob für diese Bestellung/) // T09 ans Team: Vertragsart, Ergebnis, Frist, Eingang assert.equal(t09().subject, '[Abo] WIDERRUF eingegangen: k@example.invalid') assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`) assert.equal(zeile(t09(), 'Abo: '), 'Abo: Projektauftrag') pruefeT09('Auftragsbestätigung auf „widerrufen“ gesetzt', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'RECHTZEITIG') assert.doesNotMatch(t09().text, /KEIN aktives Abo|Stripe-Abo/) }) test('bestätigte AB mit verlangtem Frühbeginn: wird widerrufen, T09 mahnt die Prüfung des Wertersatzes an', async () => { seed({ gesendetAm: vor(5), status: 'bestaetigt', bestaetigtAm: vor(4), bestaetigtUeber: 'link', fruehbeginn: true }) assert.equal((await widerruf()).status, 200) const ab = await getAuftrag('ab1') assert.equal(ab.status, 'widerrufen') assert.notEqual(ab.bestaetigtAm, null) assert.equal(zeile(t09(), 'Ergebnis: '), 'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)') }) test('Unternehmen: kein gesetzliches Widerrufsrecht – AB bleibt unverändert, K04 weist darauf hin, T09 sagt es dem Team', async () => { seed({ gesendetAm: vor(3), kunde: kunde('unternehmen') }) assert.equal((await widerruf()).status, 200) assert.equal((await getAuftrag('ab1')).status, 'versendet') assert.ok(k04().text.includes(`Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. ${UNTERNEHMEN_HINWEIS}`), k04().text) pruefeT09('Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert', 'entfällt (Unternehmen)', 'ungeprüft') }) test('nach Fristende: nichts geändert, T09 nennt das Fristende und bittet um Prüfung; K04 ohne Unternehmen-Hinweis', async () => { const gesendetAm = vor(20) seed({ gesendetAm }) assert.equal((await widerruf()).status, 200) assert.equal((await getAuftrag('ab1')).status, 'versendet') pruefeT09('nichts geändert', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'NACH Fristende, vor einer Ablehnung bitte prüfen') assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags')) assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS)) }) test('ohne, mit ungültiger oder falscher Nummer und mit fremder E-Mail-Adresse: dieselbe Antwort, nichts geändert, K04 und T09 gehen trotzdem raus (ungeprüft)', async () => { const faelle = [ [{ abNummer: 'AB-2026-0002' }, 'Projektauftrag (Auftragsbestätigung AB-2026-0002)', 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0002“ und dieser E-Mail-Adresse gefunden, nichts geändert'], [{ email: 'fremd@example.invalid' }, LABEL, 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0001“ und dieser E-Mail-Adresse gefunden, nichts geändert'], [{ abNummer: 'AB-2026-1' }, OHNE_NUMMER, KEINE_NUMMER], [{ abNummer: '' }, OHNE_NUMMER, KEINE_NUMMER], [{ abNummer: undefined }, OHNE_NUMMER, KEINE_NUMMER], [{ abNummer: 'irgendwas' }, OHNE_NUMMER, KEINE_NUMMER], ] seed({ gesendetAm: vor(3) }) for (const [body, label, ergebnis] of faelle) { sink.sent.length = 0 resetPublicRateLimit() const res = await widerruf(body) const was = JSON.stringify(body) assert.equal(res.status, 200, was) assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt'], was) assert.equal((await getAuftrag('ab1')).status, 'versendet', was) assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${label}`, was) assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${label}`, was) pruefeT09(ergebnis, 'keine Frist gefunden', 'ungeprüft') assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.'), was) assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS), was) } }) test('Groß-/Kleinschreibung und Leerzeichen bei Nummer und E-Mail-Adresse spielen keine Rolle', async () => { seed({ gesendetAm: vor(3) }) assert.equal((await widerruf({ abNummer: ' ab-2026-0001 ', email: 'K@Example.INVALID' })).status, 200) assert.equal((await getAuftrag('ab1')).status, 'widerrufen') assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`) }) test('bereits widerrufen, abgerechnet, ersetzt, storniert: nichts geändert, T09 sagt warum', async () => { const faelle = [ ['widerrufen', 'war schon widerrufen, nichts geändert', 'bereits widerrufen'], ['abgerechnet', 'Status „abgerechnet“, nichts geändert, bitte prüfen', 'ungeprüft'], ['ersetzt', 'Status „ersetzt“, nichts geändert, bitte prüfen', 'ungeprüft'], ['storniert', 'Status „storniert“, nichts geändert, bitte prüfen', 'ungeprüft'], ] for (const [status, ergebnis, eingang] of faelle) { const gesendetAm = vor(3) seed({ gesendetAm, status, widerrufenAm: status === 'widerrufen' ? vor(1) : null }) sink.sent.length = 0 resetPublicRateLimit() assert.equal((await widerruf()).status, 200, status) pruefeT09(ergebnis, `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, eingang) assert.equal((await getAuftrag('ab1')).status, status, status) } }) test('nach dem Widerruf bestätigt der Link nichts mehr: 410 mit Grund', async () => { seed({ gesendetAm: vor(3) }) assert.equal((await widerruf()).status, 200) const lesen = await send('GET', `/api/auftrag/${TOKEN}`) assert.deepEqual([lesen.status, lesen.body], [410, { error: 'Diese Auftragsbestätigung wurde widerrufen.' }]) assert.equal((await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { body: { bestaetigt: true } })).status, 410) assert.equal((await getAuftrag('ab1')).bestaetigtAm, null) }) test('Schalter aus: der Widerruf wird trotzdem verarbeitet (Verbraucherrecht), der Link antwortet wie bei unbekannter AB', async () => { seed({ gesendetAm: vor(3) }) config.auftraege.mode = 'off' assert.equal((await widerruf()).status, 200) assert.equal((await getAuftrag('ab1')).status, 'widerrufen') assert.equal((await send('GET', `/api/auftrag/${TOKEN}`)).status, 404) }) test('Datenbankfehler bei der Prüfung: Antwort 200, nichts geändert, T09 meldet die fehlgeschlagene Prüfung, K04 geht raus', async () => { seed({ gesendetAm: vor(3) }) aw.failNext({ collection: 'auftraege', method: 'GET', status: 503 }) assert.equal((await widerruf()).status, 200) assert.equal((await getAuftrag('ab1')).status, 'versendet') pruefeT09('ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen', 'keine Frist gefunden', 'ungeprüft') assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags')) assert.ok(protokoll.mock.callCount() > 0) })