import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync, readdirSync } from 'node:fs' import { FILTER, ABSAGE_GRUENDE, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag, fehltText, fensterAufteilen, heuteBerlin, istSperre, istTag, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel, } from '../public/termine-view.js' import { exportUrl, request } from '../public/termine-dom.js' // Reine Helfer der Portal-Ansicht Terminplaner (public/termine-view.js): Tage sind Berliner Kalendertage 'JJJJ-MM-TT', gerechnet wird // nur mit dem Kalender (kein Browser-Datum), die Uhrzeit des Endes kommt aus endsAt in Berliner Zeit. test('fensterParsen: Zeitfenster aus Text, normalisiert', () => { assert.deepEqual(fensterParsen('09:00–12:30, 13:30–18:00'), [['09:00', '12:30'], ['13:30', '18:00']]) assert.deepEqual(fensterParsen('9:00 - 12:30; 13:30 bis 18:00'), [['09:00', '12:30'], ['13:30', '18:00']]) assert.deepEqual(fensterParsen('9.00—24:00'), [['09:00', '24:00']]) assert.deepEqual(fensterParsen('08:00-12:00 und 14:00-18:00'), [['08:00', '12:00'], ['14:00', '18:00']]) assert.deepEqual(fensterParsen(' '), [], 'leer = an diesem Tag geschlossen') assert.deepEqual(fensterParsen(''), []) }) test('fensterParsen: ungültige Eingaben ⇒ null', () => { for (const schlecht of [ 'abc', '09:00', '09:00–', '–12:00', '12:00–09:00', '09:00–09:00', '09:07–12:00', '25:00–26:00', '24:00–24:00', '09:00–12:00, 11:00–13:00', '13:00–14:00, 09:00–10:00', '09:00–12:00,', '09:00–12:00 13:00–14:00', null, undefined, 5, ]) { assert.equal(fensterParsen(schlecht), null, String(schlecht)) } }) test('fensterText ist die Umkehrung von fensterParsen', () => { assert.equal(fensterText([['09:00', '12:30'], ['13:30', '18:00']]), '09:00–12:30, 13:30–18:00') assert.equal(fensterText([]), '') assert.equal(fensterText(undefined), '') assert.deepEqual(fensterParsen(fensterText([['08:00', '24:00']])), [['08:00', '24:00']]) }) test('zeitNormal: HH:MM, Ende darf 24:00 sein', () => { assert.equal(zeitNormal('9:05'), '09:05') assert.equal(zeitNormal(' 09:00 '), '09:00') assert.equal(zeitNormal('17.30'), '17:30') assert.equal(zeitNormal('24:00'), null) assert.equal(zeitNormal('24:00', { ende: true }), '24:00') assert.equal(zeitNormal('24:05', { ende: true }), null) assert.equal(zeitNormal('12:07'), '12:07', 'Zeit ohne 5er-Raster wird nur normalisiert, der Server prüft das Raster') assert.equal(zeitNormal('12:60'), null) assert.equal(zeitNormal('abc'), null) assert.equal(zeitNormal(''), null) assert.equal(zeitNormal(undefined), null) }) test('statusLabel: alle acht Status deutsch, Unbekanntes nie roh', () => { assert.deepEqual( ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen'].map(statusLabel), ['Reserviert', 'Angefragt', 'Bestätigt', 'Erledigt', 'Nicht erschienen', 'Abgesagt', 'Abgelehnt', 'Verfallen'], ) assert.equal(statusLabel('geheim'), 'Unbekannt') assert.equal(statusLabel(undefined), 'Unbekannt') for (const s of ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']) { assert.match(statusTon(s), /^(ok|wait|off|bad)$/, s) } assert.equal(statusTon('bestaetigt'), 'ok') assert.equal(statusTon('angefragt'), 'wait') }) test('Kalender: wocheVon (Montag bis Sonntag), monatVon, tagPlus über Monats- und Jahresgrenzen', () => { assert.deepEqual(wocheVon('2026-10-05'), { von: '2026-10-05', bis: '2026-10-11' }, 'Montag') assert.deepEqual(wocheVon('2026-10-11'), { von: '2026-10-05', bis: '2026-10-11' }, 'Sonntag gehört zur Woche davor') assert.deepEqual(wocheVon('2026-10-07'), { von: '2026-10-05', bis: '2026-10-11' }) assert.deepEqual(wocheVon('2026-12-31'), { von: '2026-12-28', bis: '2027-01-03' }, 'Jahreswechsel') assert.deepEqual(monatVon('2026-10-15'), { von: '2026-10-01', bis: '2026-10-31' }) assert.deepEqual(monatVon('2028-02-10'), { von: '2028-02-01', bis: '2028-02-29' }, 'Schaltjahr') assert.deepEqual(monatVon('2026-12-01'), { von: '2026-12-01', bis: '2026-12-31' }) assert.equal(tagPlus('2026-10-31', 1), '2026-11-01') assert.equal(tagPlus('2026-01-01', -1), '2025-12-31') assert.equal(tagPlus('2026-03-29', 1), '2026-03-30', 'Zeitumstellung spielt beim Kalender keine Rolle') assert.equal(tagPlus('2026-10-25', 1), '2026-10-26') }) test('bereichFuer und verschiebeTag je Ansicht', () => { assert.deepEqual(bereichFuer('tag', '2026-10-07', '2026-10-05'), { von: '2026-10-07', bis: '2026-10-07' }) assert.deepEqual(bereichFuer('woche', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-10-11' }) assert.deepEqual(bereichFuer('monat', '2026-10-07', '2026-10-05'), { von: '2026-10-01', bis: '2026-10-31' }) assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-12-04' }, 'ab heute bis zum Ende des Buchungszeitraums (Vorgabe 60 Tage)') assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 180), { von: '2026-10-05', bis: '2027-04-03' }, 'Buchungszeitraum der Einstellung') assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 365), { von: '2026-10-05', bis: '2027-10-05' }) assert.equal(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 9999).bis, '2027-10-05', 'höchstens 365 Tage') for (const kaputt of [0, -5, NaN, 'x', null, undefined]) assert.equal(bereichFuer('anfragen', '2026-10-07', '2026-10-05', kaputt).bis, '2026-12-04', `ungültiger Zeitraum ${String(kaputt)} ⇒ Vorgabe`) assert.equal(verschiebeTag('tag', '2026-10-31', 1), '2026-11-01') assert.equal(verschiebeTag('tag', '2026-10-01', -1), '2026-09-30') assert.equal(verschiebeTag('woche', '2026-10-05', 1), '2026-10-12') assert.equal(verschiebeTag('woche', '2026-10-05', -1), '2026-09-28') assert.equal(verschiebeTag('monat', '2026-10-17', 1), '2026-11-01') assert.equal(verschiebeTag('monat', '2026-01-17', -1), '2025-12-01') assert.equal(verschiebeTag('monat', '2026-12-31', 1), '2027-01-01') }) test('fensterAufteilen: lückenlose Abfragefenster von höchstens 62 Tagen nach dem Beginn (Grenze der Schnittstelle)', () => { assert.deepEqual(fensterAufteilen('2026-10-05', '2026-10-05'), [{ von: '2026-10-05', bis: '2026-10-05' }]) assert.deepEqual(fensterAufteilen('2026-10-05', '2026-12-04'), [{ von: '2026-10-05', bis: '2026-12-04' }], '60 Tage: ein Aufruf') assert.deepEqual(fensterAufteilen('2026-10-05', '2026-12-06'), [{ von: '2026-10-05', bis: '2026-12-06' }], 'genau 62 Tage nach dem Beginn: noch ein Aufruf') assert.deepEqual(fensterAufteilen('2026-10-05', '2026-12-07'), [{ von: '2026-10-05', bis: '2026-12-06' }, { von: '2026-12-07', bis: '2026-12-07' }]) const jahr = fensterAufteilen('2026-10-05', '2027-10-05') assert.equal(jahr.length, 6, '365 Tage: höchstens 6 Aufrufe') assert.equal(jahr[0].von, '2026-10-05') assert.equal(jahr.at(-1).bis, '2027-10-05') for (let i = 0; i < jahr.length; i += 1) { assert.ok(jahr[i].bis <= tagPlus(jahr[i].von, 62), `Fenster ${i} nicht länger als 62 Tage`) if (i > 0) assert.equal(jahr[i].von, tagPlus(jahr[i - 1].bis, 1), `Fenster ${i} schließt ohne Lücke und Überlappung an`) } assert.deepEqual(fensterAufteilen('2026-10-05', '2026-10-01'), [], 'Ende vor Beginn') }) test('istTag: nur echte Kalendertage aus einem vernünftigen Jahr (Datumsfeld mit 5-stelligem Jahr darf nichts auslösen)', () => { for (const gut of ['2026-10-05', '2028-02-29', '2000-01-01', '2100-12-31']) assert.equal(istTag(gut), true, gut) for (const schlecht of ['12345-01-01', '+12345-01-01', '2026-02-30', '2026-13-01', '2026-00-10', '2027-02-29', '0001-01-01', '1999-12-31', '2101-01-01', '2026-1-5', '', 'abc', null, undefined, 20261005]) { assert.equal(istTag(schlecht), false, String(schlecht)) } }) test('Daten: datumLang, datumKurz, tagUeberschrift, zeitraumTitel', () => { assert.equal(datumLang('2026-10-05'), 'Montag, 5. Oktober 2026') assert.equal(datumLang('2026-03-01'), 'Sonntag, 1. März 2026') assert.equal(datumKurz('2026-10-05'), '05.10.2026') assert.equal(tagUeberschrift('2026-10-05', '2026-10-05'), 'Heute · Montag, 5. Oktober') assert.equal(tagUeberschrift('2026-10-06', '2026-10-05'), 'Morgen · Dienstag, 6. Oktober') assert.equal(tagUeberschrift('2026-10-04', '2026-10-05'), 'Gestern · Sonntag, 4. Oktober') assert.equal(tagUeberschrift('2026-10-12', '2026-10-05'), 'Montag, 12. Oktober') assert.equal(tagUeberschrift('2027-01-04', '2026-10-05'), 'Montag, 4. Januar 2027', 'anderes Jahr nennt das Jahr') assert.equal(zeitraumTitel('tag', '2026-10-05'), 'Montag, 5. Oktober 2026') assert.equal(zeitraumTitel('woche', '2026-10-07'), '5.–11. Oktober 2026') assert.equal(zeitraumTitel('woche', '2026-10-28'), '26. Oktober – 1. November 2026') assert.equal(zeitraumTitel('woche', '2026-12-30'), '28. Dezember 2026 – 3. Januar 2027') assert.equal(zeitraumTitel('monat', '2026-10-17'), 'Oktober 2026') assert.equal(zeitraumTitel('anfragen', '2026-10-17'), 'Offene Anfragen') }) test('heuteBerlin: der Berliner Kalendertag, nicht der des Browsers', () => { assert.equal(heuteBerlin(new Date('2026-10-04T22:30:00Z')), '2026-10-05', '00:30 Berlin (MESZ) ist schon Montag') assert.equal(heuteBerlin(new Date('2026-10-05T21:59:00Z')), '2026-10-05') assert.equal(heuteBerlin(new Date('2026-12-31T23:30:00Z')), '2027-01-01', 'MEZ') assert.match(heuteBerlin(), /^\d{4}-\d{2}-\d{2}$/) }) test('endeZeit: Ende des Termins in Berliner Wanduhr aus endsAt', () => { assert.equal(endeZeit({ endsAt: '2026-10-05T08:45:00.000Z' }), '10:45') assert.equal(endeZeit({ endsAt: '2026-12-01T08:45:00.000Z' }), '09:45', 'Winterzeit') assert.equal(endeZeit({ endsAt: '2026-10-05T22:00:00.000Z' }), '24:00', 'Ende um Mitternacht ist 24:00, nicht 00:00 (22:00–24:00)') assert.equal(endeZeit({ endsAt: '2026-10-05T22:05:00.000Z' }), '00:05', 'nach Mitternacht bleibt es 00:05') assert.equal(endeZeit({ endsAt: 'kaputt' }), '') assert.equal(endeZeit({}), '') }) const B = (extra = {}) => ({ id: 'b1', status: 'bestaetigt', quelle: 'online', startsAt: '2026-10-05T08:00:00.000Z', endsAt: '2026-10-05T08:45:00.000Z', datum: '2026-10-05', zeit: '10:00', gastName: 'Max', gastEmail: 'max@example.com', gastTelefon: '', gastNachricht: '', ...extra, }) test('gruppiereNachTag: nach Berliner Tag, Tage und Termine aufsteigend, Termine der Liste bleiben vollständig', () => { const liste = [ B({ id: 'c', datum: '2026-10-06', zeit: '09:00', startsAt: '2026-10-06T07:00:00.000Z' }), B({ id: 'b', datum: '2026-10-05', zeit: '11:00', startsAt: '2026-10-05T09:00:00.000Z' }), B({ id: 'a', datum: '2026-10-05', zeit: '10:00', startsAt: '2026-10-05T08:00:00.000Z' }), ] const gruppen = gruppiereNachTag(liste) assert.deepEqual(gruppen.map((g) => g.tag), ['2026-10-05', '2026-10-06']) assert.deepEqual(gruppen[0].buchungen.map((b) => b.id), ['a', 'b']) assert.deepEqual(gruppen[1].buchungen.map((b) => b.id), ['c']) assert.deepEqual(gruppiereNachTag([]), []) assert.deepEqual(gruppiereNachTag(undefined), []) assert.deepEqual(liste.map((b) => b.id), ['c', 'b', 'a'], 'Eingabe bleibt unverändert') }) test('istSperre: nur quelle sperre', () => { assert.equal(istSperre({ quelle: 'sperre' }), true) assert.equal(istSperre({ quelle: 'manuell' }), false) assert.equal(istSperre({ quelle: 'online' }), false) assert.equal(istSperre({}), false) }) test('moeglicheAktionen: je Status und Zeit; Sperren nie mit Gast-Aktionen', () => { const vorher = Date.parse('2026-10-05T07:00:00Z') // vor Beginn const mitten = Date.parse('2026-10-05T08:10:00Z') // nach Beginn const danach = Date.parse('2026-10-05T09:00:00Z') // nach Ende assert.deepEqual(moeglicheAktionen(B({ status: 'angefragt' }), vorher), ['annehmen', 'ablehnen', 'verschieben', 'anonymisieren']) assert.deepEqual(moeglicheAktionen(B({ status: 'angefragt' }), mitten), ['ablehnen', 'anonymisieren'], 'begonnene Anfrage lässt sich nicht mehr annehmen') assert.deepEqual(moeglicheAktionen(B({ status: 'bestaetigt' }), vorher), ['verschieben', 'absagen', 'anonymisieren']) assert.deepEqual(moeglicheAktionen(B({ status: 'bestaetigt' }), mitten), ['nichterschienen', 'anonymisieren']) assert.deepEqual(moeglicheAktionen(B({ status: 'bestaetigt' }), danach), ['nichterschienen', 'anonymisieren']) assert.deepEqual(moeglicheAktionen(B({ status: 'erledigt' }), danach), ['nichterschienen', 'anonymisieren']) for (const s of ['nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']) assert.deepEqual(moeglicheAktionen(B({ status: s }), danach), ['anonymisieren'], s) assert.deepEqual(moeglicheAktionen(B({ status: 'gehalten' }), vorher), [], 'Reservierung wartet auf den Gast') // Gastdaten schon gelöscht oder nie erfasst: nichts zu löschen assert.deepEqual(moeglicheAktionen(B({ status: 'abgesagt', gastName: 'Gelöscht', gastEmail: '' }), danach), []) assert.deepEqual(moeglicheAktionen(B({ status: 'abgesagt', gastName: '', gastEmail: '' }), danach), []) assert.deepEqual(moeglicheAktionen(B({ status: 'abgesagt', gastName: 'Gelöscht', gastEmail: 'x@y.de' }), danach), ['anonymisieren']) // Sperre: nur aufheben, solange sie noch nicht vorbei ist assert.deepEqual(moeglicheAktionen(B({ quelle: 'sperre', status: 'bestaetigt', gastName: '' }), vorher), ['entsperren']) assert.deepEqual(moeglicheAktionen(B({ quelle: 'sperre', status: 'bestaetigt', gastName: '' }), mitten), ['entsperren']) assert.deepEqual(moeglicheAktionen(B({ quelle: 'sperre', status: 'erledigt', gastName: '' }), danach), []) }) test('Filter: Statuslisten für die Schnittstelle', () => { assert.deepEqual(FILTER.map((f) => f.key), ['alle', 'offen', 'angefragt', 'bestaetigt', 'abgesagt']) assert.equal(filterStatus('alle'), '') assert.equal(filterStatus('offen'), 'angefragt,bestaetigt') assert.equal(filterStatus('angefragt'), 'angefragt') assert.equal(filterStatus('bestaetigt'), 'bestaetigt') assert.equal(filterStatus('abgesagt'), 'abgesagt,abgelehnt,verfallen') assert.equal(filterStatus('gibtsnicht'), '') }) test('anfragenText: Einzahl und Mehrzahl', () => { assert.equal(anfragenText(0), '') assert.equal(anfragenText(1), '1 Anfrage wartet') assert.equal(anfragenText(2), '2 Anfragen warten') assert.equal(anfragenText(17), '17 Anfragen warten') assert.equal(anfragenText(undefined), '') }) test('Absagegründe: Auswahl mit kurzen Texten (Server: höchstens 100 Zeichen)', () => { assert.ok(ABSAGE_GRUENDE.length >= 4) for (const g of ABSAGE_GRUENDE) assert.ok(g.length > 0 && [...g].length <= 100, g) assert.equal(new Set(ABSAGE_GRUENDE).size, ABSAGE_GRUENDE.length) }) test('ressourcenWort: Person, Platz oder beides', () => { assert.equal(ressourcenWort([{ typ: 'person' }, { typ: 'person' }]), 'Person') assert.equal(ressourcenWort([{ typ: 'platz' }]), 'Platz') assert.equal(ressourcenWort([{ typ: 'person' }, { typ: 'platz' }]), 'Person/Platz') assert.equal(ressourcenWort([]), 'Person/Platz') assert.equal(ressourcenWort(undefined), 'Person/Platz') }) test('fehltText: Pflichtangaben für Gäste in Worten', () => { assert.equal(fehltText(['betrieb.telefon']), 'deine Telefonnummer') assert.equal(fehltText(['betrieb.telefon', 'betrieb.adresse']), 'deine Telefonnummer und deine Anschrift') assert.equal(fehltText(['betrieb.adresse']), 'deine Anschrift') assert.equal(fehltText([]), '') assert.equal(fehltText(['unbekannt']), '', 'Unbekanntes nie roh') assert.equal(fehltText(undefined), '') }) // ---- Verdrahtung (Portal-Ansicht #termine) const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8') test('Portal: Ansicht #termine ist verdrahtet, funktionen.js behält seinen einen absoluten Import', () => { const app = lies('public/app.js') assert.match(app, /VIEW_HASHES = \{[^}]*termine: 'termine'/) assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen'/) assert.match(app, /FEATURE_LABELS = \{[^}]*termine: 'Terminplaner'/) assert.match(app, /termine: \(\) => import\('\/termine\.js'\)\.then\(\(m\) => m\.initTermine\(document\.getElementById\('termine-body'\)\)\)/) const html = lies('public/dashboard.html') assert.match(html, /
[\s\S]*

Terminplaner<\/h1>[\s\S]*
/) assert.match(html, //) // R4: funktionen.js lädt nichts Neues (die Kachel steht inline); test/funktionenTiles.test.js biegt genau diesen Import um const fn = lies('public/funktionen.js') assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'"]) assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import') }) test('Ansicht-Module (public/termine*.js): Fremdtext nur als Text, keine Browser-Dialoge, Du-Form, ein einziger Link', () => { const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^termine.*\.js$/.test(f)).sort() assert.deepEqual(dateien, ['termine-aktionen.js', 'termine-dom.js', 'termine-formulare.js', 'termine-tab-termine.js', 'termine-view.js', 'termine.js']) const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') const quellen = dateien.map((f) => [f, ohneKommentare(lies(`public/${f}`))]) for (const [datei, quelle] of quellen) { assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${datei}: Daten nie als HTML`) assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: Rückfragen stehen in der Seite`) assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, `${datei}: Portaltexte in der Du-Form`) assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie/, `${datei}: kein Browserspeicher`) } // Links nur auf die eigene Export-Schnittstelle (exportUrl), nie auf Kundentext const hrefs = quellen.flatMap(([datei, quelle]) => (quelle.match(/href: [^,}]+|setAttribute\('href'/g) || []).map((m) => `${datei}: ${m}`)) assert.deepEqual(hrefs, ['termine.js: href: exportUrl(projectId)']) const dom = quellen.find(([f]) => f === 'termine-dom.js')[1] assert.ok(dom.includes('`/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`'), 'exportUrl codiert die Projekt-ID') }) test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kennt sie nur ueber REITER', () => { const huelle = lies('public/termine.js') assert.match(huelle, /import \{ render as renderTermine \} from '\.\/termine-tab-termine\.js'/) assert.match(huelle, /const REITER = \[/) assert.match(lies('public/termine-tab-termine.js'), /export function render\(panel, ctx\)/) // Reiter lesen Zustand nur aus ctx, nicht aus der Huelle for (const datei of ['termine-tab-termine.js', 'termine-aktionen.js', 'termine-formulare.js']) { assert.doesNotMatch(lies(`public/${datei}`), /from '\.\/termine\.js'/, `${datei} importiert die Huelle nicht`) } }) test('request: Netzwerkfehler auf Deutsch, Fehler der Schnittstelle mit Feldern und Code', async () => { const vorher = globalThis.fetch try { globalThis.fetch = async () => { throw new TypeError('Failed to fetch') } await assert.rejects(request('/api/termine/buchungen'), (err) => err.message === 'Keine Verbindung. Bitte versuch es gleich noch einmal.' && err.status === 0 && !/fetch/i.test(err.message)) globalThis.fetch = async () => ({ ok: false, status: 400, json: async () => ({ error: 'Bitte einen Namen angeben.', fehler: { name: 'Bitte einen Namen angeben.' }, code: 'x' }) }) await assert.rejects(request('/api/termine/buchungen'), (err) => err.status === 400 && err.fehler.name === 'Bitte einen Namen angeben.' && err.code === 'x') globalThis.fetch = async () => ({ ok: false, status: 502, json: async () => { throw new Error('kein JSON') } }) await assert.rejects(request('/x'), /Server nicht erreichbar/) } finally { globalThis.fetch = vorher } }) test('exportUrl: Projekt-ID wird codiert', () => { assert.equal(exportUrl('p1'), '/api/termine/export.csv?projectId=p1') assert.equal(exportUrl('a&b=c d'), '/api/termine/export.csv?projectId=a%26b%3Dc%20d') })