#!/usr/bin/env node /** * End-to-end check of the clinic autocomplete and the registration/domain rules. * * npm run build && npm run test:registration * * Starts the built Nitro server (.output/server/index.mjs) against a THROWAWAY * registrations database and an unreachable SMTP port, then drives the real HTTP * endpoints with real rows from data/clinics.db. Nothing is mocked. * * Each submission carries its own X-Forwarded-For so the per-IP throttle in * server/api/registration/submit.post.ts does not interfere. * * Section 8 covers the language switch: which language the API answers in, that the * choice is stored on the row (it is what decides the mail language), and that the * server-rendered page sets from ?lang=, the cookie and Accept-Language. * * Section 9 covers the collapse of the three registration types into two ('klinik' + * 'kurierdienst'): the retired 'abgebende_klinik' / 'beziehende_klinik' are rejected as * INPUT, but a row already carrying one still labels and still confirms — that asymmetry * is the whole backward-compatibility contract, so it is tested on a row written straight * into the database, which is the only way an old value can reach the server now. * * Section 10 covers AUTOMATIC ACTIVATION — the `needs_manual_approval` column as actually * stored, not just the flag in the HTTP response. Manual approval is now the exception: * a verified domain and an operator test domain are both activated straight away, while a * clinic with no domain on record and every courier still wait for a human. The server is * therefore started WITH a REGISTRATION_TEST_DOMAINS value (see TEST_DOMAIN below), which * is the only way the bypass can be exercised at all. */ import fs from 'node:fs' import path from 'node:path' import process from 'node:process' import { spawn } from 'node:child_process' import Database from 'better-sqlite3' const ROOT = path.resolve(import.meta.dirname, '..') const PORT = Number(process.env.TEST_PORT || 3399) const BASE = `http://127.0.0.1:${PORT}` const TEST_DB = path.join(ROOT, 'data', 'test-registrations.db') const CLINIC_DB = path.join(ROOT, 'data', 'clinics.db') const SERVER = path.join(ROOT, '.output', 'server', 'index.mjs') // Operator test domain for section 10. Uses the reserved .example TLD so it can never // collide with a real hospital domain in clinics.db, and it is not on the freemail list — // both matter, because the bypass must be the ONLY reason this address is accepted. const TEST_DOMAIN = 'blut24-testdomain.example' let passed = 0 let failed = 0 const failures = [] const ok = (name, detail = '') => { passed++; console.log(` \x1b[32m✔\x1b[0m ${name}${detail ? ` — ${detail}` : ''}`) } const bad = (name, detail = '') => { failed++ failures.push(`${name}${detail ? ` — ${detail}` : ''}`) console.log(` \x1b[31m✘\x1b[0m ${name}${detail ? ` — ${detail}` : ''}`) } const assert = (cond, name, detail = '') => (cond ? ok(name, detail) : bad(name, detail)) const section = (title) => console.log(`\n\x1b[1m${title}\x1b[0m`) // ── fixtures from the real clinic database ──────────────────────────────────────────── if (!fs.existsSync(CLINIC_DB)) { console.error('data/clinics.db fehlt — bitte zuerst "npm run import:clinics" ausführen.') process.exit(1) } if (!fs.existsSync(SERVER)) { console.error('.output/server/index.mjs fehlt — bitte zuerst "npm run build" ausführen.') process.exit(1) } const cdb = new Database(CLINIC_DB, { readonly: true }) const clinicCount = cdb.prepare('SELECT COUNT(*) AS n FROM clinics').get().n const withDomain = cdb.prepare("SELECT COUNT(*) AS n FROM clinics WHERE domain <> ''").get().n const groupDomain = cdb.prepare(` SELECT domain, COUNT(DISTINCT stoid) AS n FROM clinic_domains GROUP BY domain ORDER BY n DESC LIMIT 1 `).get() const groupClinics = cdb.prepare(` SELECT c.* FROM clinic_domains d JOIN clinics c ON c.stoid = d.stoid WHERE d.domain = ? ORDER BY c.name LIMIT 3 `).all(groupDomain.domain) const otherDomainClinic = cdb.prepare(` SELECT c.* FROM clinics c WHERE c.domain <> '' AND c.domain <> ? AND c.stoid NOT IN (SELECT stoid FROM clinic_domains WHERE domain = ?) ORDER BY c.id LIMIT 1 `).get(groupDomain.domain, groupDomain.domain) const noDomainClinic = cdb.prepare("SELECT * FROM clinics WHERE domains = '' ORDER BY id LIMIT 1").get() const plzSample = cdb.prepare("SELECT * FROM clinics WHERE plz <> '' ORDER BY id LIMIT 1").get() // Six Standorte nobody else in this run registers — sections 8, 9 and 10 each need a clean // slate, because one live registration per Standort is all the duplicate guard allows. const spareClinics = cdb.prepare(` SELECT * FROM clinics WHERE domain <> '' AND domain <> ? AND domain <> ? ORDER BY id DESC LIMIT 6 `).all(groupDomain.domain, otherDomainClinic.domain) const langClinics = spareClinics.slice(0, 2) const legacyClinics = spareClinics.slice(2, 4) // Section 10 registers this one from an operator TEST domain. It deliberately HAS a domain // of its own on record, so the only thing that can make the foreign address acceptable is // the REGISTRATION_TEST_DOMAINS bypass. const testDomainClinic = spareClinics[4] cdb.close() console.log('\x1b[1mBlut24 — Registrierungs- und Suchprüfung\x1b[0m') console.log(` Kliniken in der Datenbank : ${clinicCount} (mit Domain: ${withDomain})`) console.log(` Verbund-Domain : ${groupDomain.domain} (${groupDomain.n} Standorte)`) console.log(` Standort ohne Domain : ${noDomainClinic ? `${noDomainClinic.name} (${noDomainClinic.stoid})` : '— keiner gefunden —'}`) // ── server ──────────────────────────────────────────────────────────────────────────── for (const suffix of ['', '-wal', '-shm']) { const file = TEST_DB + suffix if (fs.existsSync(file)) fs.rmSync(file) } const server = spawn(process.execPath, [SERVER], { cwd: ROOT, env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', NITRO_PORT: String(PORT), NUXT_REGISTRATION_DB_PATH: TEST_DB, NUXT_CLINIC_DB_PATH: CLINIC_DB, NUXT_PUBLIC_SITE_URL: BASE, NUXT_SMTP_HOST: '127.0.0.1', NUXT_SMTP_PORT: '1', // guarantees a fast, caught SMTP failure NUXT_NOTIFY_EMAIL: '', ERP_ONBOARDING_URL: '', // ERP gate stays off → must no-op, not crash ERP_SERVICE_TOKEN: '', // Read once at module load inside the server, so it must be set on spawn. REGISTRATION_TEST_DOMAINS: TEST_DOMAIN, }, stdio: ['ignore', 'pipe', 'pipe'], }) const serverLog = [] server.stdout.on('data', (d) => serverLog.push(String(d))) server.stderr.on('data', (d) => serverLog.push(String(d))) const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const waitForServer = async () => { for (let i = 0; i < 80; i++) { try { const res = await fetch(`${BASE}/api/clinics/search?q=kli`) if (res.ok) return true } catch { /* not up yet */ } await sleep(250) } return false } let ipCounter = 0 const submit = async (body) => { ipCounter++ const res = await fetch(`${BASE}/api/registration/submit`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': `10.0.${Math.floor(ipCounter / 250)}.${ipCounter % 250}`, }, body: JSON.stringify(body), }) return res.json() } const getJson = async (url) => (await fetch(BASE + url)).json() const baseContact = { contactName: 'Anna Beispiel', phone: '+49 30 1234567', acceptTerms: true, } const run = async () => { // ── 1 · autocomplete ──────────────────────────────────────────────────────────────── section('1 · Autovervollständigung (FTS über clinics.db)') const byName = await getJson(`/api/clinics/search?q=${encodeURIComponent(groupClinics[0].name.split(' ')[0])}`) assert(byName.count > 0, 'Suche nach Klinikname liefert Treffer', `"${groupClinics[0].name.split(' ')[0]}" → ${byName.count}`) const exact = await getJson(`/api/clinics/search?q=${encodeURIComponent(groupClinics[0].name.slice(0, 28))}`) assert( (exact.results || []).some((r) => r.stoid === groupClinics[0].stoid), 'Exakter Klinikname findet genau diesen Standort', groupClinics[0].name, ) const byPlz = await getJson(`/api/clinics/search?q=${plzSample.plz}`) assert( (byPlz.results || []).some((r) => r.plz === plzSample.plz), 'Suche nach Postleitzahl funktioniert', `${plzSample.plz} → ${byPlz.count} Treffer`, ) const umlaut = await getJson('/api/clinics/search?q=M%C3%BCnchen') const umlautAscii = await getJson('/api/clinics/search?q=Muenchen') const umlautPlain = await getJson('/api/clinics/search?q=Munchen') assert(umlaut.count > 0 && umlautAscii.count > 0 && umlautPlain.count > 0, 'Umlaut-Varianten finden dieselben Städte', `München ${umlaut.count} · Muenchen ${umlautAscii.count} · Munchen ${umlautPlain.count}`) const short = await getJson('/api/clinics/search?q=k') assert(short.count === 0, 'Einzelnes Zeichen liefert keine Suche (Mindestlänge 2)') const noResults = await getJson('/api/clinics/search?q=zzzqqqxxx') assert(noResults.count === 0, 'Unsinnige Anfrage liefert leeres Ergebnis') assert( !Object.prototype.hasOwnProperty.call(byName.results[0] || {}, 'email'), 'Suchergebnis enthält keine Klinik-E-Mail-Adresse (Datensparsamkeit)', ) // ── 2 · domain → Standorte ────────────────────────────────────────────────────────── section('2 · Domain-Auflösung (Klinikverbünde)') const domainRes = await getJson(`/api/clinics/by-domain?domain=${groupDomain.domain}`) assert(domainRes.count === groupDomain.n, 'Verbund-Domain liefert alle zugehörigen Standorte', `${groupDomain.domain} → ${domainRes.count} (erwartet ${groupDomain.n})`) const domainByEmail = await getJson(`/api/clinics/by-domain?email=chefarzt%40klinik.${groupDomain.domain}`) assert(domainByEmail.count === groupDomain.n, 'Subdomain einer Adresse wird auf die registrierbare Domain reduziert', `klinik.${groupDomain.domain} → ${domainByEmail.count}`) const freemailRes = await getJson('/api/clinics/by-domain?email=test%40gmx.de') assert(freemailRes.freemail === true && freemailRes.count === 0, 'Freemail-Domain wird als solche erkannt') // ── 3 · Registrierung Klinik ──────────────────────────────────────────────────────── section('3 · Registrierung — Klinik') const clinicA = groupClinics[0] const clinicB = groupClinics[1] const okRes = await submit({ ...baseContact, type: 'klinik', stoid: clinicA.stoid, email: `a.beispiel@${groupDomain.domain}`, }) assert(Number(okRes.status) === 200 && okRes.needsManualApproval === false, 'Passende Verbund-Domain wird akzeptiert', `${clinicA.name} · ${groupDomain.domain}`) const secondSite = await submit({ ...baseContact, type: 'klinik', stoid: clinicB.stoid, email: `b.beispiel@${groupDomain.domain}`, }) assert(Number(secondSite.status) === 200, 'Zweiter Standort desselben Verbunds kann sich ebenfalls registrieren', clinicB.name) const wrongDomain = await submit({ ...baseContact, type: 'klinik', stoid: clinicA.stoid, email: `c.beispiel@${otherDomainClinic.domain}`, }) assert(Number(wrongDomain.status) === 422 && !!wrongDomain.errors?.email, 'Fremde Domain wird abgelehnt', `${otherDomainClinic.domain} bei ${clinicA.name}`) assert((wrongDomain.domainClinics || []).some((c) => c.stoid === otherDomainClinic.stoid), 'Abgelehnte Domain schlägt die passenden Standorte vor', `${(wrongDomain.domainClinics || []).length} Vorschlag/Vorschläge`) for (const freemail of ['gmx.de', 'web.de', 'gmail.com', 't-online.de', 'outlook.com']) { const res = await submit({ ...baseContact, type: 'klinik', stoid: clinicA.stoid, email: `privat@${freemail}`, }) assert(Number(res.status) === 422 && !!res.errors?.email, `Freemail abgelehnt: ${freemail}`) } if (noDomainClinic) { const manual = await submit({ ...baseContact, type: 'klinik', stoid: noDomainClinic.stoid, email: 'leitung@eine-beliebige-praxisdomain.de', }) assert(Number(manual.status) === 200 && manual.needsManualApproval === true, 'Klinik ohne hinterlegte Domain → manuelle Freigabe', noDomainClinic.name) } else { bad('Klinik ohne hinterlegte Domain vorhanden', 'kein Testdatensatz gefunden') } const noStoid = await submit({ ...baseContact, type: 'klinik', email: `x@${groupDomain.domain}` }) assert(Number(noStoid.status) === 422 && !!noStoid.errors?.stoid, 'Klinik ohne Standortauswahl wird abgelehnt') const noConsent = await submit({ ...baseContact, acceptTerms: false, type: 'klinik', stoid: groupClinics[2].stoid, email: `d.beispiel@${groupDomain.domain}`, }) assert(Number(noConsent.status) === 422 && !!noConsent.errors?.acceptTerms, 'Fehlende Einwilligung wird abgelehnt') // ── 4 · Duplikat ──────────────────────────────────────────────────────────────────── section('4 · Duplikatsschutz (ohne Preisgabe)') const duplicate = await submit({ ...baseContact, contactName: 'Bernd Zweitversuch', type: 'klinik', stoid: clinicA.stoid, email: `zweiter.versuch@${groupDomain.domain}`, }) assert(Number(duplicate.status) === 200, 'Doppelte Registrierung erhält dieselbe neutrale Antwort') assert(!/bereits|schon|existiert/i.test(String(duplicate.message)), 'Antwort verrät nicht, dass die Klinik bereits registriert ist') // ── 5 · Kurierdienst ──────────────────────────────────────────────────────────────── section('5 · Registrierung — Medizinischer Kurierdienst') const courier = await submit({ ...baseContact, type: 'kurierdienst', orgName: 'Muster Medizinlogistik GmbH', street: 'Industriestraße 12', plz: '10115', city: 'Berlin', email: 'dispo@muster-medizinlogistik.de', }) assert(Number(courier.status) === 200 && courier.needsManualApproval === true, 'Kurierdienst wird angenommen und manuell geprüft') const courierIncomplete = await submit({ ...baseContact, type: 'kurierdienst', orgName: 'Unvollständig GmbH', email: 'dispo@unvollstaendig-gmbh.de', }) assert(Number(courierIncomplete.status) === 422 && !!courierIncomplete.errors?.street && !!courierIncomplete.errors?.plz, 'Unvollständige Kurier-Adresse wird abgelehnt') const courierFreemail = await submit({ ...baseContact, type: 'kurierdienst', orgName: 'Freemail Kurier GmbH', street: 'Weg 1', plz: '20095', city: 'Hamburg', email: 'kurier@web.de', }) // Behaviour deliberately changed: a courier is not tied to a clinic domain and every // courier registration is reviewed manually, so a small firm on a freemail address must // not be turned away. The block stays for CLINICS — asserted separately above. assert(Number(courierFreemail.status) === 200 && !courierFreemail.errors?.email, 'Kurierdienst mit Freemail-Adresse wird akzeptiert (nicht an eine Klinik-Domain gebunden)') // ── 6 · Honeypot ──────────────────────────────────────────────────────────────────── section('6 · Spamschutz') const bot = await submit({ ...baseContact, type: 'klinik', stoid: groupClinics[2].stoid, email: `bot@${groupDomain.domain}`, website: 'http://spam.example', }) assert(Number(bot.status) === 200, 'Honeypot-Absender erhält eine unauffällige Erfolgsmeldung') // ── 7 · Bestätigung ───────────────────────────────────────────────────────────────── section('7 · Double-Opt-in') const rdb = new Database(TEST_DB, { readonly: true }) const stored = rdb.prepare('SELECT * FROM pending_registrations ORDER BY id').all() const pendingA = stored.find((r) => r.stoid === clinicA.stoid && r.status === 'pending') const duplicateRow = stored.find((r) => r.status === 'duplicate') const botRow = stored.find((r) => r.contact_email === `bot@${groupDomain.domain}`) rdb.close() assert(!!pendingA, 'Registrierung wurde gespeichert', `#${pendingA?.id}`) assert(pendingA?.domain_verified === 1, 'Domain-Prüfung ist im Datensatz vermerkt') assert(!!duplicateRow && duplicateRow.status === 'duplicate', 'Doppelversuch wird intern als "duplicate" protokolliert', `#${duplicateRow?.id}`) assert(!botRow, 'Honeypot-Eingabe wurde nicht gespeichert') const badToken = await (await fetch(`${BASE}/api/registration/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: 'voellig-ungueltig' }), })).json() assert(Number(badToken.status) === 404, 'Ungültiger Bestätigungslink wird abgewiesen') const confirmed = await (await fetch(`${BASE}/api/registration/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: pendingA.token }), })).json() assert(Number(confirmed.status) === 200 && confirmed.alreadyConfirmed === false, 'Bestätigungslink bestätigt die Registrierung', confirmed.orgName) assert(confirmed.credentialsSent === false, 'ERP-Onboarding ist ohne ERP_ONBOARDING_URL deaktiviert (kein Absturz)') const twice = await (await fetch(`${BASE}/api/registration/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: pendingA.token }), })).json() assert(Number(twice.status) === 200 && twice.alreadyConfirmed === true, 'Zweiter Aufruf desselben Links ist idempotent') const rdb2 = new Database(TEST_DB, { readonly: true }) const after = rdb2.prepare('SELECT * FROM pending_registrations WHERE id = ?').get(pendingA.id) rdb2.close() assert(after.status === 'confirmed' && !!after.confirmed_at, 'Status im Datensatz ist "confirmed"') assert(after.erp_status === 'disabled', 'ERP-Ergebnis ist als "disabled" protokolliert') // ── 8 · Sprache ───────────────────────────────────────────────────────────────────── section('8 · Sprachumschaltung (Deutsch als Vorgabe)') // Fehlermeldungen der API in der Sprache des Formulars — ohne Angabe: Deutsch. const invalid = { type: 'klinik', contactName: 'Anna Beispiel', acceptTerms: true } const errDefault = await submit({ ...invalid }) const errEn = await submit({ ...invalid, lang: 'en' }) const errBogus = await submit({ ...invalid, lang: 'fr' }) assert(Number(errDefault.status) === 422 && /markierten Felder/.test(String(errDefault.message)), 'Ohne lang antwortet die API auf Deutsch') assert(Number(errEn.status) === 422 && errEn.message === 'Please check the highlighted fields.' && errEn.errors?.email === 'Please enter your work e-mail address.', 'Mit lang=en antwortet die API auf Englisch') assert(/markierten Felder/.test(String(errBogus.message)), 'Unbekanntes lang fällt auf Deutsch zurück', 'lang=fr') // Die Sprache der Registrierung landet auf dem Datensatz — sie bestimmt die Mailsprache. const [enClinic, deClinic] = langClinics const enReg = await submit({ ...baseContact, lang: 'en', type: 'klinik', stoid: enClinic.stoid, email: `jane@${enClinic.domain}`, }) const deReg = await submit({ ...baseContact, lang: 'de', type: 'klinik', stoid: deClinic.stoid, email: `hans@${deClinic.domain}`, }) assert(Number(enReg.status) === 200 && String(enReg.message).startsWith('Thank you.'), 'Englische Registrierung erhält die englische Bestätigungsmeldung') assert(Number(deReg.status) === 200 && String(deReg.message).startsWith('Vielen Dank!'), 'Deutsche Registrierung erhält die deutsche Bestätigungsmeldung') const rdb3 = new Database(TEST_DB, { readonly: true }) const langRows = rdb3.prepare( 'SELECT contact_email, lang, token FROM pending_registrations WHERE contact_email IN (?, ?)', ).all(`jane@${enClinic.domain}`, `hans@${deClinic.domain}`) rdb3.close() const enRow = langRows.find((r) => r.contact_email.startsWith('jane@')) const deRow = langRows.find((r) => r.contact_email.startsWith('hans@')) assert(enRow?.lang === 'en' && deRow?.lang === 'de', 'Sprache ist auf dem Datensatz gespeichert (Grundlage der Mailsprache)', `en=${enRow?.lang} de=${deRow?.lang}`) // Die Bestätigungsantwort folgt der Seitensprache, der Typ wird mit übersetzt. const confirmEn = await (await fetch(`${BASE}/api/registration/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: enRow.token, lang: 'en' }), })).json() // Der erwartete Typ heißt seit der Zusammenlegung 'hospital' statt 'receiving hospital' // — die Registrierung oben wird als 'klinik' abgeschickt. Geprüft wird unverändert, dass // die Bestätigung in der Seitensprache antwortet UND den Typ übersetzt mitliefert. assert(Number(confirmEn.status) === 200 && confirmEn.message === 'Thank you. Your e-mail address is confirmed.' && confirmEn.typeLabel === 'hospital', 'Bestätigung antwortet auf Englisch inklusive übersetztem Typ') const confirmDe = await (await fetch(`${BASE}/api/registration/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: deRow.token, lang: 'de' }), })).json() assert(Number(confirmDe.status) === 200 && /bestätigt/.test(String(confirmDe.message)) && confirmDe.typeLabel === 'Klinik', 'Bestätigung antwortet auf Deutsch inklusive deutschem Typ') // Server-Rendering: folgt ?lang=, Cookie und Accept-Language. const htmlLang = async (init) => { const res = await fetch(`${BASE}${init.path || '/'}`, { headers: init.headers || {} }) const html = await res.text() return { lang: (html.match(/]*\slang="([^"]+)"/) || [])[1] || '', setCookie: res.headers.get('set-cookie') || '', html, } } const plain = await htmlLang({}) const queryEn = await htmlLang({ path: '/?lang=en' }) const headerEn = await htmlLang({ headers: { 'Accept-Language': 'en-GB,en;q=0.9' } }) const headerBoth = await htmlLang({ headers: { 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8' } }) const headerTie = await htmlLang({ headers: { 'Accept-Language': 'en;q=0.9,de;q=0.9' } }) const cookieEn = await htmlLang({ headers: { Cookie: 'b24_lang=en' } }) assert(plain.lang === 'de', 'Ohne Hinweis rendert die Seite auf Deutsch', ``) assert(queryEn.lang === 'en', '?lang=en rendert die Seite auf Englisch') assert(/b24_lang=en/.test(queryEn.setCookie), '?lang=en wird als Cookie gespeichert') assert(cookieEn.lang === 'en', 'Cookie b24_lang=en rendert die Seite auf Englisch') assert(headerEn.lang === 'en', 'Accept-Language ohne Deutsch rendert Englisch') assert(headerBoth.lang === 'de' && headerTie.lang === 'de', 'Deutsch gewinnt, sobald der Accept-Language-Header mehrdeutig ist', 'de bevorzugt und Gleichstand') assert(/Ihr Vorteil/.test(plain.html) && /id="vorteil"/.test(plain.html) && /id="verfall"/.test(plain.html), 'Abschnitt heißt "Ihr Vorteil"; #vorteil und der alte Anker #verfall existieren beide') assert(/Your benefit/.test(queryEn.html) && !/Verfall & Kosten|Verfall & Kosten/.test(queryEn.html), 'Englische Fassung nutzt "Your benefit" und zeigt keine deutsche Abschnittsüberschrift') // ── 9 · Zwei Registrierungstypen + Altwerte ───────────────────────────────────────── section('9 · Zwei Typen (klinik · kurierdienst) und Alt-Werte') // 9a — der zusammengelegte Typ erbt exakt den Klinik-Pfad. const mergedClinic = legacyClinics[0] const merged = await submit({ ...baseContact, type: 'klinik', stoid: mergedClinic.stoid, email: `neu@${mergedClinic.domain}`, }) assert(Number(merged.status) === 200 && merged.needsManualApproval === false, 'Typ "klinik" wird angenommen und erbt die Domain-Prüfung der Klinik', `${mergedClinic.name} · ${mergedClinic.domain}`) const mergedNoStoid = await submit({ ...baseContact, type: 'klinik', email: `ohne.standort@${mergedClinic.domain}`, }) assert(Number(mergedNoStoid.status) === 422 && !!mergedNoStoid.errors?.stoid, 'Typ "klinik" verlangt weiterhin einen Standort aus dem Bundes-Klinik-Atlas') const mergedFreemail = await submit({ ...baseContact, type: 'klinik', stoid: mergedClinic.stoid, email: 'privat@gmx.de', }) assert(Number(mergedFreemail.status) === 422 && !!mergedFreemail.errors?.email, 'Typ "klinik" lehnt Freemail weiterhin ab') // 9b — die abgelösten Werte sind als EINGABE ungültig. Sie stehen nirgends mehr zur // Wahl; wer sie schickt, hat einen veralteten Client oder bastelt den Request selbst. for (const retired of ['abgebende_klinik', 'beziehende_klinik']) { const res = await submit({ ...baseContact, type: retired, stoid: mergedClinic.stoid, email: `alt@${mergedClinic.domain}`, }) assert(Number(res.status) === 422 && !!res.errors?.type, `Abgelöster Typ wird als Eingabe abgelehnt: ${retired}`) } const bogusType = await submit({ ...baseContact, type: 'raumschiff', stoid: mergedClinic.stoid, email: `unsinn@${mergedClinic.domain}`, }) assert(Number(bogusType.status) === 422 && !!bogusType.errors?.type, 'Unbekannter Typ wird abgelehnt') // 9c — LESEPFAD: eine Zeile, die noch einen abgelösten Wert trägt, muss weiterhin // beschriftet werden UND über ihren Token bestätigt werden können. Da die Eingabe // solche Werte nicht mehr annimmt, wird die Zeile direkt geschrieben — genau so, wie // sie vor der Zusammenlegung entstanden ist. const legacyClinic = legacyClinics[1] const wdb = new Database(TEST_DB) const insertLegacy = wdb.prepare(` INSERT INTO pending_registrations (token, type, status, needs_manual_approval, stoid, org_name, street, plz, city, land, contact_name, contact_email, contact_phone, email_domain, domain_verified, lang, note, created_at, ip, user_agent) VALUES (@token, @type, 'pending', 0, @stoid, @org_name, @street, @plz, @city, @land, 'Alt Bestand', @contact_email, '', @email_domain, 1, @lang, '', @created_at, '', '') `) const legacyRows = [ { token: 'legacy-de-token', type: 'abgebende_klinik', lang: 'de', contact_email: `alt.de@${legacyClinic.domain}`, stoid: legacyClinic.stoid }, { token: 'legacy-en-token', type: 'beziehende_klinik', lang: 'en', contact_email: `alt.en@${legacyClinic.domain}`, stoid: null }, ] for (const row of legacyRows) { insertLegacy.run({ ...row, org_name: legacyClinic.name, street: legacyClinic.street, plz: legacyClinic.plz, city: legacyClinic.city, land: legacyClinic.land, email_domain: legacyClinic.domain, created_at: new Date().toISOString(), }) } wdb.close() const legacyDe = await (await fetch(`${BASE}/api/registration/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: 'legacy-de-token', lang: 'de' }), })).json() assert(Number(legacyDe.status) === 200 && legacyDe.alreadyConfirmed === false, 'Alter Bestätigungslink ("abgebende_klinik") bestätigt weiterhin', legacyDe.orgName) assert(legacyDe.typeLabel === 'Klinik', 'Alter Wert wird auf das deutsche Etikett "Klinik" abgebildet', `→ ${legacyDe.typeLabel}`) const legacyEn = await (await fetch(`${BASE}/api/registration/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: 'legacy-en-token', lang: 'en' }), })).json() assert(Number(legacyEn.status) === 200 && legacyEn.alreadyConfirmed === false, 'Alter Bestätigungslink ("beziehende_klinik") bestätigt weiterhin') assert(legacyEn.typeLabel === 'hospital', 'Alter Wert wird auf das englische Etikett "hospital" abgebildet', `→ ${legacyEn.typeLabel}`) assert(!/_klinik/.test(String(legacyEn.typeLabel)) && !/_klinik/.test(String(legacyDe.typeLabel)), 'Der interne Schlüssel taucht nie in einer sichtbaren Beschriftung auf') // Die Altzeilen bleiben unverändert stehen — es gibt keine Migration, die sie umschreibt. const rdb4 = new Database(TEST_DB, { readonly: true }) const legacyStored = rdb4.prepare( 'SELECT token, type, status FROM pending_registrations WHERE token IN (?, ?)', ).all('legacy-de-token', 'legacy-en-token') rdb4.close() assert(legacyStored.length === 2 && legacyStored.every((r) => r.status === 'confirmed') && legacyStored.some((r) => r.type === 'abgebende_klinik') && legacyStored.some((r) => r.type === 'beziehende_klinik'), 'Altwerte bleiben in der Datenbank stehen (keine zerstörende Migration)', legacyStored.map((r) => r.type).join(' · ')) // 9d — der Kurierpfad ist unverändert (in Abschnitt 5 vollständig geprüft). const courierStill = await submit({ ...baseContact, type: 'kurierdienst', orgName: 'Zweite Medizinlogistik GmbH', street: 'Hafenstraße 3', plz: '28195', city: 'Bremen', email: 'dispo@zweite-medizinlogistik.de', }) assert(Number(courierStill.status) === 200 && courierStill.needsManualApproval === true, 'Kurierdienst bleibt von der Zusammenlegung unberührt') // 9e — beide Sprachen zeigen genau zwei Optionen, die dritte Fassung ist verschwunden. const optionCount = (html, ...titles) => titles.filter((title) => html.includes(`>${title}<`)).length assert(/Klinik/.test(plain.html) && /Medizinischer Kurierdienst/.test(plain.html), 'Deutsche Seite bietet "Klinik" und "Medizinischer Kurierdienst" an') assert(!/Abgebende Klinik|Beziehende Klinik/.test(plain.html), 'Deutsche Seite zeigt die dreiteilige Auswahl nicht mehr') assert(/Hospital/.test(queryEn.html) && /Medical courier service/.test(queryEn.html), 'Englische Seite bietet "Hospital" und "Medical courier service" an') assert(!/Supplying hospital|Receiving hospital/.test(queryEn.html), 'Englische Seite zeigt die dreiteilige Auswahl nicht mehr') assert(optionCount(plain.html, 'Klinik', 'Medizinischer Kurierdienst') === 2 && optionCount(queryEn.html, 'Hospital', 'Medical courier service') === 2, 'Beide Sprachen rendern genau zwei Auswahlkarten') // Die Vorauswahl-Karten des Abschnitts #registrierung tragen dieselben zwei Rollen. assert(!/abgebende_klinik|beziehende_klinik/.test(plain.html) && !/abgebende_klinik|beziehende_klinik/.test(queryEn.html), 'Kein abgelöster Typ-Schlüssel steckt mehr im ausgelieferten HTML') // ── 10 · Automatische Freischaltung ───────────────────────────────────────────────── section('10 · Automatische Freischaltung (needs_manual_approval im Datensatz)') // Eine Testdomain darf sich für JEDEN Standort registrieren — und wird seit der // Umstellung wie eine geprüfte Registrierung sofort freigeschaltet. Der gewählte // Standort hat eine eigene, andere Domain hinterlegt: ohne den Bypass wäre diese // Adresse mit 422 abgelehnt worden (Abschnitt 3 prüft genau das). const testDomainRes = await submit({ ...baseContact, type: 'klinik', stoid: testDomainClinic.stoid, email: `pruefung@${TEST_DOMAIN}`, }) assert(Number(testDomainRes.status) === 200 && testDomainRes.needsManualApproval === false, 'Testdomain wird angenommen und OHNE manuelle Freigabe freigeschaltet', `${testDomainClinic.name} · ${TEST_DOMAIN} statt ${testDomainClinic.domain}`) const rdb5 = new Database(TEST_DB, { readonly: true }) const allRows = rdb5.prepare('SELECT * FROM pending_registrations').all() rdb5.close() const rowFor = (email) => allRows.find((r) => r.contact_email === email) || null // Die vier Fälle der Regel, gelesen aus der gespeicherten Spalte — nicht aus der Antwort. const verifiedRow = rowFor(`a.beispiel@${groupDomain.domain}`) // Abschnitt 3 const testRow = rowFor(`pruefung@${TEST_DOMAIN}`) // gerade eben const noDomainRow = rowFor('leitung@eine-beliebige-praxisdomain.de') // Abschnitt 3 const courierRow = rowFor('dispo@muster-medizinlogistik.de') // Abschnitt 5 assert(verifiedRow?.needs_manual_approval === 0 && verifiedRow?.domain_verified === 1, 'Geprüfte Domain → sofort aktiv (needs_manual_approval = 0)', `#${verifiedRow?.id}`) assert(testRow?.needs_manual_approval === 0, 'Testdomain → sofort aktiv (needs_manual_approval = 0)', `#${testRow?.id}`) // Die Prüfspur bleibt: nicht domain-verifiziert und im Datensatz als Test markiert. // Genau daran erkennt eine Bearbeiterin die Zeile, seit die Freigabe-Spalte es nicht // mehr tut. assert(testRow?.domain_verified === 0, 'Testregistrierung wird NICHT als domain-verifiziert gezählt') assert(new RegExp(`^\\[TESTREGISTRIERUNG via ${TEST_DOMAIN}\\]`).test(String(testRow?.note)), 'Testregistrierung bleibt im Datensatz als solche vermerkt (Prüfspur)', String(testRow?.note || '').slice(0, 48)) if (noDomainClinic) { assert(noDomainRow?.needs_manual_approval === 1, 'Klinik ohne hinterlegte Domain → manuelle Freigabe (needs_manual_approval = 1)', `#${noDomainRow?.id} · ${noDomainClinic.name}`) } else { bad('Klinik ohne hinterlegte Domain → manuelle Freigabe', 'kein Testdatensatz gefunden') } assert(courierRow?.needs_manual_approval === 1, 'Kurierdienst → manuelle Freigabe (needs_manual_approval = 1)', `#${courierRow?.id}`) // Und die Bestätigung trägt die Entscheidung weiter: keine "wir prüfen manuell"-Zusage // in der Willkommensmail, weil needsManualApproval auch dort false bleibt. const testConfirm = await (await fetch(`${BASE}/api/registration/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: testRow.token, lang: 'de' }), })).json() assert(Number(testConfirm.status) === 200 && testConfirm.needsManualApproval === false, 'Bestätigung einer Testregistrierung meldet keine manuelle Freigabe', testConfirm.orgName) // Die Gegenrichtung: eine Registrierung MIT manueller Freigabe (Kurier) wird beim // Bestätigen NICHT an das ERP übergeben — das Versprechen "wir prüfen manuell" wird // gehalten. confirm.post.ts protokolliert die Zurückstellung als 'manual-approval'. const courierConfirm = await (await fetch(`${BASE}/api/registration/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: courierRow.token, lang: 'de' }), })).json() assert(Number(courierConfirm.status) === 200 && courierConfirm.needsManualApproval === true && courierConfirm.credentialsSent === false, 'Bestätigung eines Kurierdienstes meldet die manuelle Freigabe und KEINE Zugangsdaten', courierConfirm.orgName) const rdb6 = new Database(TEST_DB, { readonly: true }) const courierAfter = rdb6.prepare('SELECT * FROM pending_registrations WHERE id = ?').get(courierRow.id) rdb6.close() assert(courierAfter?.status === 'confirmed' && courierAfter?.erp_status === 'manual-approval', 'ERP-Onboarding wird für manuelle Freigaben zurückgestellt (erp_status = "manual-approval")', courierAfter?.erp_message) } const main = async () => { const up = await waitForServer() if (!up) { console.error('\nServer konnte nicht gestartet werden:\n' + serverLog.join('')) server.kill('SIGKILL') process.exit(1) } try { await run() } catch (err) { bad('Unerwarteter Fehler', err?.message || String(err)) console.error(err) } server.kill('SIGKILL') console.log('') console.log('─'.repeat(64)) console.log(` Bestanden: ${passed} Fehlgeschlagen: ${failed}`) if (failed) { console.log('\n Fehlgeschlagene Prüfungen:') for (const f of failures) console.log(` · ${f}`) } console.log('─'.repeat(64)) process.exit(failed ? 1 : 0) } main()