import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { installFakeDom } from './support/fakeDom.js' import { h, request } from '../public/shop-dom.js' import { REITER } from '../public/buchungen.js' import { render, zeitraumText, naechteText } from '../public/buchungen-tab-anfragen.js' import { summeLive, baueEditor } from '../public/buchungen-posten.js' // Task 12: Gerüst und Reiter „Anfragen“ gegen eine Anfrage-Attrappe nach docs/funktionen/unterkunft-api.md. const POSTEN = [ { text: 'Übernachtung', menge: 7, einzelCent: 5500, ustProzent: 7 }, { text: 'Reinigung', menge: 1, einzelCent: 6000, ustProzent: 7 }, { text: 'Direktbuchungsrabatt 10 %', menge: 1, einzelCent: -3850, ustProzent: 7 }, ] const zeile = (nr, extra = {}) => ({ id: `a${nr}`, nummer: `A-${nr}`, von: '2026-11-12', bis: '2026-11-30', gast: { name: `Gast ${nr}` }, status: 'neu', summen: { bruttoCent: 40650 }, ...extra }) const anfrage = (extra = {}) => ({ id: 'a1', nummer: 'A-1', von: '2026-11-12', bis: '2026-11-19', personen: 2, status: 'neu', steuerart: 'regelbesteuert', unterkunftName: 'Haus am See', gast: { name: 'Anna Muster', email: 'anna@example.com', telefon: '0151', nachricht: 'Hund dabei' }, ...extra, }) const vorschlag = { naechte: 7, posten: POSTEN, summen: { bruttoCent: 40650 } } const mit = (route, fn) => async () => { const fx = installFakeDom({ antworten: Array.from({ length: 40 }, () => route) }) try { await fn(fx) } finally { fx.restore() } } function baueCtx() { const ctx = { projectId: 'p1', status: { state: 'active' }, nurLesen: false, meldungen: [], aktuell: () => true, request, zeigeMeldung: (t, a) => ctx.meldungen.push({ text: t, art: a }), neuLaden: async () => ctx.status } return ctx } async function zeige(fx) { const ctx = baueCtx() const panel = h('div') fx.document.body.append(panel) await render(panel, ctx) await fx.ruhe() return { panel, ctx } } const server = (o = {}) => (a) => { const key = `${a.method} ${a.url.split('?')[0]}` if (o[key]) return o[key](a) if (a.method === 'GET' && a.url.startsWith('/api/unterkunft/anfragen?')) return { body: { anfragen: [zeile(1), zeile(2, { status: 'gebucht' })] } } if (a.method === 'GET' && a.url.startsWith('/api/unterkunft/anfragen/')) return { body: { anfrage: anfrage(), vorschlag } } return { body: {} } } const posts = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET') const aktion = (fx, el, n) => fx.finde(el, `[data-aktion="${n}"]`) const oeffne = async (fx, panel, id = 'a1') => { await fx.klick(fx.finde(panel, `[data-anfrage="${id}"]`)); await fx.ruhe() } test('REITER: fünf Reiter mit den Labels', () => { assert.deepEqual(REITER.map((r) => r.key), ['anfragen', 'kalender', 'unterkuenfte', 'rechnungen', 'einstellungen']) assert.deepEqual(REITER.map((r) => r.label), ['Anfragen', 'Kalender', 'Unterkünfte & Preise', 'Rechnungen', 'Einstellungen']) }) test('summeLive spiegelt summiere: 40650 / 37991 / 2659', () => { const s = summeLive(POSTEN, 'regelbesteuert') assert.equal(s.bruttoCent, 40650) assert.equal(s.nettoCent, 37991) assert.equal(s.ust[0].steuerCent, 2659) const k = summeLive(POSTEN, 'kleinunternehmer') assert.deepEqual(k, { bruttoCent: 40650, nettoCent: 40650, ust: [] }) }) test('Zeitraum und Nächte', () => { assert.equal(zeitraumText('2026-11-12', '2026-11-30'), '12.–30.11.2026') assert.equal(zeitraumText('2026-10-28', '2026-11-03'), '28.10.–03.11.2026') assert.equal(zeitraumText('2026-12-28', '2027-01-03'), '28.12.2026–03.01.2027') assert.equal(naechteText('2026-11-12', '2026-11-30'), '18 Nächte') assert.equal(naechteText('2026-11-12', '2026-11-13'), '1 Nacht') }) test('Liste: Nummer, Zeitraum, Gast, Status-Pille, Betrag; Filter offen als Standard', mit(server(), async (fx) => { const { panel } = await zeige(fx) assert.equal(fx.fetchLog[0].url, '/api/unterkunft/anfragen?projectId=p1&filter=offen') const z = fx.alle(panel, '[data-anfrage]') assert.equal(z.length, 2) const t = fx.text(z[0]) for (const re of [/A-1/, /12\.–30\.11\.2026 · 18 Nächte/, /Gast 1/, /Neu/, /406,50\s€/]) assert.match(t, re) assert.match(fx.text(z[1]), /Gebucht/) await fx.setze(fx.finde(panel, '#bk-a-filter'), 'alle') await fx.ruhe() assert.equal(fx.fetchLog.at(-1).url, '/api/unterkunft/anfragen?projectId=p1&filter=alle') })) test('Detail: Vorschlag editierbar, Summe aktualisiert sich, Freigeben sendet posten/fristTage/nachricht und zeigt den Link', mit(server({ 'POST /api/unterkunft/anfragen/a1/freigeben': () => ({ body: { buchungsLink: 'https://x.test/buchen/a1?t=abc' } }), }), async (fx) => { const { panel, ctx } = await zeige(fx) await oeffne(fx, panel) assert.match(fx.text(panel), /Anna Muster<\/b>/) assert.equal(fx.finde(panel, 'b'), null, 'Fremdtext bleibt Text') assert.match(fx.text(fx.finde(panel, '[data-summen]')), /Gesamt 406,50\s€/) const zeilen = fx.alle(panel, '[data-zeile]') assert.equal(zeilen.length, 3) const einzel = fx.finde(zeilen[1], '[data-posten="einzel"]') await fx.setze(einzel, '70,00') assert.match(fx.text(fx.finde(panel, '[data-summen]')), /Gesamt 416,50\s€/) await fx.setze(einzel, '60,00') await fx.setze(fx.finde(panel, '#bk-a-frist'), '5') await fx.setze(fx.finde(panel, '#bk-a-nachricht'), 'Gern!') await fx.klick(aktion(fx, panel, 'freigeben')) await fx.ruhe() const [p] = posts(fx) assert.equal(p.url, '/api/unterkunft/anfragen/a1/freigeben?projectId=p1') assert.deepEqual(p.body, { posten: POSTEN, fristTage: 5, nachricht: 'Gern!' }) assert.equal(fx.finde(panel, '[data-link]').getAttribute('value'), 'https://x.test/buchen/a1?t=abc') assert.ok(aktion(fx, panel, 'link-kopieren')) assert.equal(ctx.meldungen.at(-1).art, 'ok') })) test('Freigeben: 409 zeigt „Der Zeitraum ist inzwischen belegt.“', mit(server({ 'POST /api/unterkunft/anfragen/a1/freigeben': () => ({ status: 409, body: { fehler: 'zeitraum_belegt' } }), }), async (fx) => { const { panel } = await zeige(fx) await oeffne(fx, panel) await fx.klick(aktion(fx, panel, 'freigeben')) await fx.ruhe() assert.match(fx.text(panel), /Der Zeitraum ist inzwischen belegt\./) assert.equal(aktion(fx, panel, 'freigeben').disabled, false) })) test('Freigeben: 409 ohne Rechnungsauftrag zeigt die Zustimmungs-Meldung, nicht „belegt“', mit(server({ 'POST /api/unterkunft/anfragen/a1/freigeben': () => ({ status: 409, body: { fehler: 'zustimmung_fehlt' } }), }), async (fx) => { const { panel } = await zeige(fx) await oeffne(fx, panel) await fx.klick(aktion(fx, panel, 'freigeben')) await fx.ruhe() assert.match(fx.text(panel), /Bitte erteile zuerst in den Einstellungen die Zustimmung zur Rechnungsstellung in deinem Namen\./) assert.doesNotMatch(fx.text(panel), /belegt/) assert.equal(aktion(fx, panel, 'freigeben').disabled, false) })) test('Freigeben: 409 status_ungueltig zeigt den Stand-Hinweis, nicht „belegt“', mit(server({ 'POST /api/unterkunft/anfragen/a1/freigeben': () => ({ status: 409, body: { fehler: 'status_ungueltig' } }), }), async (fx) => { const { panel } = await zeige(fx) await oeffne(fx, panel) await fx.klick(aktion(fx, panel, 'freigeben')) await fx.ruhe() assert.match(fx.text(panel), /Lade die Seite neu und prüfe den Status\./) assert.doesNotMatch(fx.text(panel), /belegt/) })) test('Ablehnen sendet den Text', mit(server({ 'POST /api/unterkunft/anfragen/a1/ablehnen': () => ({ body: {} }) }), async (fx) => { const { panel } = await zeige(fx) await oeffne(fx, panel) await fx.klick(aktion(fx, panel, 'ablehnen')) await fx.setze(fx.finde(panel, '#bk-a-ablehn'), 'Leider belegt') await fx.klick(aktion(fx, panel, 'ablehnen-senden')) await fx.ruhe() assert.deepEqual(posts(fx)[0].body, { text: 'Leider belegt' }) })) const gebucht = (extra = {}) => ({ anfrage: anfrage({ status: 'gebucht', posten: POSTEN, summen: { bruttoCent: 40650 }, rechnungId: 'r1', rechnungNummer: 'R-2026-001', ...extra }) }) test('Gebucht: Rechnung-Link, „Als bezahlt (vor Ort)“, Storno ohne Erstattungsfeld bei Vor-Ort-Zahlung', mit(server({ 'GET /api/unterkunft/anfragen/a1': () => ({ body: gebucht({ zahlart: 'vorort' }) }), 'POST /api/unterkunft/anfragen/a1/bezahlt': () => ({ body: {} }), 'POST /api/unterkunft/anfragen/a1/stornieren': () => ({ body: {} }), }), async (fx) => { const { panel } = await zeige(fx) await oeffne(fx, panel) assert.equal(aktion(fx, panel, 'rechnung').getAttribute('href'), '/api/unterkunft/rechnungen/r1.pdf?projectId=p1') await fx.klick(aktion(fx, panel, 'bezahlt')) await fx.ruhe() assert.equal(posts(fx)[0].url, '/api/unterkunft/anfragen/a1/bezahlt?projectId=p1') await fx.klick(aktion(fx, panel, 'stornieren')) assert.equal(fx.finde(panel, '#bk-a-erstattung'), null) await fx.setze(fx.finde(panel, '#bk-a-grund'), 'Gast krank') await fx.klick(aktion(fx, panel, 'storno-senden')) await fx.ruhe() assert.deepEqual(posts(fx).at(-1).body, { grund: 'Gast krank', erstattungCent: 0 }) })) test('Storno bei Online-Zahlung: Erstattungsbetrag wird gesendet', mit(server({ 'GET /api/unterkunft/anfragen/a1': () => ({ body: gebucht({ status: 'bezahlt', zahlart: 'online', bezahltVia: 'stripe' }) }), 'POST /api/unterkunft/anfragen/a1/stornieren': () => ({ body: {} }), }), async (fx) => { const { panel } = await zeige(fx) await oeffne(fx, panel) assert.equal(aktion(fx, panel, 'bezahlt'), null) await fx.klick(aktion(fx, panel, 'stornieren')) assert.equal(fx.finde(panel, '#bk-a-erstattung').value, '406,50') await fx.setze(fx.finde(panel, '#bk-a-erstattung'), '200,00') await fx.klick(aktion(fx, panel, 'storno-senden')) await fx.ruhe() assert.equal(posts(fx)[0].body.erstattungCent, 20000) })) // Task 13: Reiter „Kalender“ und „Unterkünfte & Preise“ (Antwortformen: ui-erwartungen.md) const UK = { id: 'u1', name: 'Haus am See', beschreibung: 'Schön', maxPersonen: 4, mindestNaechte: 2, checkIn: '15:00', checkOut: '10:00', sort: 0, aktiv: true, icalExportUrl: 'https://x.test/unterkunft/ical/tok.ics', icalImporte: [{ id: 'i1', name: 'Booking.com', url: 'https://b.test/x.ics', letzterErfolgAt: '2026-10-07T10:00:00.000Z', fehler: '' }, { id: 'i2', name: 'Airbnb', url: 'https://a.test/x.ics', fehler: 'Zeitüberschreitung' }] } const PREISE = { staffeln: [{ abNaechten: 1, preisProNachtCent: 5500 }, { abNaechten: 7, preisProNachtCent: 5000 }], reinigungCent: 6000, personenInklusive: 2, aufpreisPersonNachtCent: 1000, direktrabattProzent: 10, steuerart: 'regel', ustUebernachtung: 7, ustReinigung: 7, fristTage: 3, stornobedingungen: 'Kostenlos bis 14 Tage vorher.' } const BEREICHE = [ { von: '2026-11-03', bis: '2026-11-06', quelle: 'gebucht', anfrageNummer: 'A-1' }, { von: '2026-11-10', bis: '2026-11-12', quelle: 'reserviert' }, { von: '2026-11-15', bis: '2026-11-17', quelle: 'ical:i1' }, { von: '2026-11-20', bis: '2026-11-22', quelle: 'manuell', sperreId: 's1', notiz: 'Eigenbedarf' }, ] const ukServer = (o = {}) => (a) => { const key = `${a.method} ${a.url.split('?')[0]}` if (o[key]) return o[key](a) if (key === 'GET /api/unterkunft/unterkuenfte') return { body: { unterkuenfte: [UK] } } if (key === 'GET /api/unterkunft/unterkuenfte/u1/kalender') return { body: { bereiche: BEREICHE } } if (key === 'GET /api/unterkunft/unterkuenfte/u1/preise') return { body: { preise: PREISE } } if (key === 'POST /api/unterkunft/preisvorschau') return { body: { naechte: 7, posten: [], summen: { bruttoCent: 40650 } } } return { body: {} } } const zeigeTab = async (fx, modul) => { const ctx = baueCtx() const panel = h('div') fx.document.body.append(panel) await modul.render(panel, ctx) await fx.ruhe() return { panel, ctx } } const kal = await import('../public/buchungen-tab-kalender.js') const ukTab = await import('../public/buchungen-tab-unterkuenfte.js') import { setzeUhrFuerTests } from '../public/buchungen-uk-hilfe.js' setzeUhrFuerTests(() => new Date('2026-10-07T10:00:00Z')) // feste Uhr: Kalender und Sperren-Tests nutzen Daten im Herbst 2026 const tag = (fx, panel, t) => fx.finde(panel, `[data-tag="${t}"]`) const gehZuNovember = async (fx, panel) => { for (let i = 0; i < 24 && !/November 2026/.test(fx.text(fx.finde(panel, '[data-monat]'))); i += 1) { await fx.klick(aktion(fx, panel, 'weiter')); await fx.ruhe() } assert.match(fx.text(fx.finde(panel, '[data-monat]')), /November 2026/, 'November 2026 nicht erreicht') } test('rasterTage: November 2026 beginnt am Montag 26.10. und endet am Sonntag 29.11.... ', () => { const t = kal.rasterTage(2026, 10) assert.equal(t[0], '2026-10-26') assert.equal(t.length % 7, 0) assert.equal(t.at(-1), '2026-12-06') assert.equal(kal.rasterTage(2026, 5)[0], '2026-06-01') // Juni 2026 beginnt am Montag }) test('Kalender: Raster Montag zuerst, Farben nach Quelle, Legende', mit(ukServer(), async (fx) => { const { panel } = await zeigeTab(fx, kal) await gehZuNovember(fx, panel) assert.equal(fx.text(fx.alle(panel, '[role="columnheader"]')[0]), 'Mo') assert.equal(fx.alle(panel, '[data-tag]')[0].dataset.tag, '2026-10-26') assert.ok(tag(fx, panel, '2026-11-03').classList.contains('ist-gebucht')) assert.ok(!tag(fx, panel, '2026-11-06').classList.contains('ist-gebucht'), 'Abreisetag ist frei') assert.ok(tag(fx, panel, '2026-11-10').classList.contains('ist-reserviert')) assert.ok(tag(fx, panel, '2026-11-16').classList.contains('ist-ical')) assert.ok(tag(fx, panel, '2026-11-20').classList.contains('ist-manuell')) assert.ok(tag(fx, panel, '2026-11-08').classList.contains('ist-frei')) for (const t of ['Direkt gebucht', 'Reserviert', 'Booking.com / Airbnb', 'Von dir gesperrt']) assert.match(fx.text(panel), new RegExp(t)) assert.ok(fx.fetchLog.some((a) => /kalender\?projectId=p1&von=2026-10-26&bis=2026-12-07$/.test(a.url))) })) test('Kalender: zwei Klicks auf freie Tage → Notiz → POST sperren', mit(ukServer({ 'POST /api/unterkunft/unterkuenfte/u1/sperren': () => ({ status: 201, body: {} }) }), async (fx) => { const { panel } = await zeigeTab(fx, kal) await gehZuNovember(fx, panel) await fx.klick(tag(fx, panel, '2026-11-25')) assert.equal(posts(fx).length, 0) await fx.klick(tag(fx, panel, '2026-11-27')) assert.match(fx.text(panel), /Zeitraum sperren/) await fx.setze(fx.finde(panel, '#bk-k-notiz'), 'Familie') await fx.klick(aktion(fx, panel, 'sperren-senden')) await fx.ruhe() const [p] = posts(fx) assert.equal(p.url, '/api/unterkunft/unterkuenfte/u1/sperren?projectId=p1') assert.deepEqual(p.body, { von: '2026-11-25', bis: '2026-11-28', notiz: 'Familie' }) })) test('Kalender: Zeitraum über belegte Tage wird nicht angeboten; 409 vom Server wird gezeigt', mit(ukServer({ 'POST /api/unterkunft/unterkuenfte/u1/sperren': () => ({ status: 409, body: { fehler: 'zeitraum_belegt' } }) }), async (fx) => { const { panel } = await zeigeTab(fx, kal) await gehZuNovember(fx, panel) await fx.klick(tag(fx, panel, '2026-11-08')) await fx.klick(tag(fx, panel, '2026-11-13')) assert.equal(aktion(fx, panel, 'sperren-senden'), null) assert.match(fx.text(fx.finde(panel, '[data-hinweis]')), /belegte Tage/) await fx.klick(tag(fx, panel, '2026-11-24')) await fx.klick(tag(fx, panel, '2026-11-24')) await fx.klick(aktion(fx, panel, 'sperren-senden')) await fx.ruhe() assert.match(fx.text(panel), /Der Zeitraum ist bereits belegt\./) })) test('Kalender: Klick auf manuelle Sperre bietet „Sperre aufheben“ (DELETE); andere Quellen nicht', mit(ukServer({ 'DELETE /api/unterkunft/sperren/s1': () => ({ body: {} }) }), async (fx) => { const { panel } = await zeigeTab(fx, kal) await gehZuNovember(fx, panel) await fx.klick(tag(fx, panel, '2026-11-04')) assert.equal(aktion(fx, panel, 'aufheben'), null) await fx.klick(tag(fx, panel, '2026-11-21')) assert.match(fx.text(panel), /Eigenbedarf/) await fx.klick(aktion(fx, panel, 'aufheben')) await fx.ruhe() const [p] = posts(fx) assert.equal(p.method, 'DELETE') assert.equal(p.url, '/api/unterkunft/sperren/s1?projectId=p1') })) test('Unterkünfte: Liste mit Fremdtext als Text; Formular hat alle Felder; Speichern sendet PUT', mit(ukServer({ 'PUT /api/unterkunft/unterkuenfte/u1': () => ({ body: { unterkunft: UK } }) }), async (fx) => { const { panel } = await zeigeTab(fx, ukTab) assert.match(fx.text(panel), /Haus am<\/i> See/) assert.equal(fx.finde(panel, 'i'), null) await fx.klick(aktion(fx, panel, 'bearbeiten')) await fx.ruhe() for (const f of ['name', 'beschreibung', 'maxPersonen', 'mindestNaechte', 'checkIn', 'checkOut', 'aktiv']) assert.ok(fx.finde(panel, `[data-feld="${f}"]`), f) await fx.setze(fx.finde(panel, '#bk-u-max'), '6') await fx.klick(fx.finde(panel, '.bk-unterkuenfte form [data-aktion="speichern"]')) await fx.ruhe() const put = posts(fx).find((a) => a.method === 'PUT') assert.equal(put.url, '/api/unterkunft/unterkuenfte/u1?projectId=p1') assert.deepEqual(put.body, { name: UK.name, beschreibung: 'Schön', maxPersonen: 6, mindestNaechte: 2, checkIn: '15:00', checkOut: '10:00', sort: 0, aktiv: true }) })) test('Unterkünfte: Feldfehler (400 mit felder) kommen am Feld an', mit(ukServer({ 'PUT /api/unterkunft/unterkuenfte/u1': () => ({ status: 400, body: { fehler: 'ungueltig', felder: { name: 'Name ist schon vergeben.' } } }) }), async (fx) => { const { panel } = await zeigeTab(fx, ukTab) await fx.klick(aktion(fx, panel, 'bearbeiten')) await fx.ruhe() await fx.klick(fx.finde(panel, '.bk-unterkuenfte form [data-aktion="speichern"]')) await fx.ruhe() assert.equal(fx.text(fx.finde(panel, '[data-fehler="name"]')), 'Name ist schon vergeben.') })) test('Unterkünfte: Neue Unterkunft prüft Felder lokal und sendet POST', mit(ukServer({ 'POST /api/unterkunft/unterkuenfte': () => ({ status: 201, body: { unterkunft: { ...UK, id: 'u2', name: 'Neu' } } }) }), async (fx) => { const { panel } = await zeigeTab(fx, ukTab) await fx.klick(aktion(fx, panel, 'neu')) await fx.klick(aktion(fx, panel, 'speichern')) assert.match(fx.text(fx.finde(panel, '[data-fehler="name"]')), /Namen/) assert.equal(posts(fx).length, 0) await fx.setze(fx.finde(panel, '#bk-u-name'), 'Neu') await fx.klick(aktion(fx, panel, 'speichern')) await fx.ruhe() const [p] = posts(fx) assert.equal(p.method, 'POST') assert.equal(p.body.name, 'Neu') assert.equal(p.body.sort, 1) })) test('Preis-Editor: Staffelzeilen +/−, Beispielrechnung über /preisvorschau, Speichern sendet Cent', mit(ukServer({ 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }) }), async (fx) => { const { panel } = await zeigeTab(fx, ukTab) await fx.klick(aktion(fx, panel, 'bearbeiten')) await fx.ruhe() assert.match(fx.text(fx.finde(panel, '[data-vorschau]')), /7 Nächte, 2 Personen = 406,50\s€/) const vor = fx.fetchLog.find((a) => a.url.startsWith('/api/unterkunft/preisvorschau')) assert.equal(vor.method, 'POST') assert.equal(vor.body.unterkunftId, 'u1') assert.equal(vor.body.personen, 2) assert.equal(fx.alle(panel, '[data-staffel-zeile]').length, 2) await fx.klick(aktion(fx, panel, 'staffel-dazu')) assert.equal(fx.alle(panel, '[data-staffel-zeile]').length, 3) const neu = fx.alle(panel, '[data-staffel-zeile]')[2] await fx.setze(fx.finde(neu, '[data-staffel="ab"]'), '14') await fx.setze(fx.finde(neu, '[data-staffel="preis"]'), '45') await fx.klick(fx.finde(fx.alle(panel, '[data-staffel-zeile]')[1], '[data-aktion="staffel-weg"]')) assert.equal(fx.alle(panel, '[data-staffel-zeile]').length, 2) await fx.klick(aktion(fx, panel, 'preise-speichern')) await fx.ruhe() const put = posts(fx).find((a) => a.url.includes('/preise')) assert.equal(put.method, 'PUT') assert.deepEqual(put.body.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500 }, { abNaechten: 14, preisProNachtCent: 4500 }]) assert.equal(put.body.reinigungCent, 6000) assert.equal(put.body.aufpreisPersonNachtCent, 1000) assert.equal(put.body.steuerart, 'regel') assert.equal(put.body.direktrabattProzent, 10) assert.equal(put.body.stornobedingungen, 'Kostenlos bis 14 Tage vorher.') })) test('iCal: Status je Import, Fehlertext, Export-Link, Anleitungen', mit(ukServer(), async (fx) => { const { panel } = await zeigeTab(fx, ukTab) await fx.klick(aktion(fx, panel, 'bearbeiten')) await fx.ruhe() assert.match(fx.text(fx.finde(panel, '[data-import="i1"]')), /Zuletzt abgeglichen 07\.10\.2026/) assert.match(fx.text(fx.finde(panel, '[data-import="i2"]')), /nicht geklappt: Zeitüberschreitung/) assert.equal(fx.finde(panel, '[data-export]').getAttribute('value'), UK.icalExportUrl) assert.ok(aktion(fx, panel, 'export-kopieren')) assert.match(fx.text(fx.finde(panel, '[data-anleitung="booking"]')), /Unterkunft → Tarife & Verfügbarkeit → Kalender synchronisieren → Kalender importieren → Link einfügen/) assert.ok(fx.finde(panel, '[data-anleitung="airbnb"]')) })) test('iCal: Import hinzufügen (POST), entfernen mit Rückfrage, Link erneuern mit Bestätigung', mit(ukServer({ 'POST /api/unterkunft/unterkuenfte/u1/ical-importe': () => ({ status: 201, body: {} }), 'DELETE /api/unterkunft/unterkuenfte/u1/ical-importe/i1': () => ({ body: {} }), 'POST /api/unterkunft/unterkuenfte/u1/ical-export/erneuern': () => ({ body: {} }), }), async (fx) => { const { panel } = await zeigeTab(fx, ukTab) await fx.klick(aktion(fx, panel, 'bearbeiten')) await fx.ruhe() await fx.setze(fx.finde(panel, '#bk-i-name'), 'Vrbo') await fx.setze(fx.finde(panel, '#bk-i-url'), 'http://unsicher.test/x.ics') const vorher = posts(fx).length await fx.klick(aktion(fx, panel, 'import-dazu')) assert.equal(posts(fx).length, vorher, 'nur https') await fx.setze(fx.finde(panel, '#bk-i-url'), 'https://vrbo.test/x.ics') await fx.klick(aktion(fx, panel, 'import-dazu')) await fx.ruhe() assert.deepEqual(posts(fx).find((a) => a.url.includes('ical-importe')).body, { name: 'Vrbo', url: 'https://vrbo.test/x.ics' }) await fx.klick(fx.finde(fx.finde(panel, '[data-import="i1"]'), '[data-aktion="import-weg"]')) assert.equal(posts(fx).filter((a) => a.method === 'DELETE').length, 0) await fx.klick(aktion(fx, panel, 'import-weg-ok')) await fx.ruhe() assert.equal(posts(fx).find((a) => a.method === 'DELETE').url, '/api/unterkunft/unterkuenfte/u1/ical-importe/i1?projectId=p1') await fx.klick(aktion(fx, panel, 'export-erneuern')) assert.equal(posts(fx).filter((a) => a.url.includes('erneuern')).length, 0) await fx.klick(aktion(fx, panel, 'erneuern-ok')) await fx.ruhe() assert.equal(posts(fx).filter((a) => a.url.includes('erneuern')).length, 1) })) test('Freigeben: Serverfehler 400 mit felder zeigt genau den Feldtext', mit(server({ 'POST /api/unterkunft/anfragen/a1/freigeben': () => ({ status: 400, body: { fehler: 'ungueltig', felder: { posten: 'Die Summe muss größer als 0 sein.' } } }), }), async (fx) => { const { panel } = await zeige(fx) await oeffne(fx, panel) await fx.klick(aktion(fx, panel, 'freigeben')) await fx.ruhe() assert.equal(fx.text(fx.finde(panel, '[data-fehler="posten"]')), 'Die Summe muss größer als 0 sein.') assert.equal(fx.text(fx.finde(panel, '[data-fehler="_"]')), '') })) // Fix round 1: Preis-Editor prüft Grenzen wie der Server const preisEditor = async (fx, preise = PREISE) => { const { panel } = await zeigeTab(fx, ukTab) await fx.klick(aktion(fx, panel, 'bearbeiten')) await fx.ruhe() return panel } const versuche = async (fx, panel) => { const vorher = posts(fx).filter((a) => a.url.includes('/preise')).length await fx.klick(aktion(fx, panel, 'preise-speichern')) await fx.ruhe() return posts(fx).filter((a) => a.url.includes('/preise')).length - vorher } const fehlerText = (fx, panel, n) => fx.text(fx.finde(panel, `[data-fehler="${n}"]`)) test('Preis-Editor: Staffeln (Anzahl, erste ab 1, aufsteigend, Preisgrenzen)', mit(ukServer({ 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }) }), async (fx) => { const panel = await preisEditor(fx) const zeilen = () => fx.alle(panel, '[data-staffel-zeile]') const ab = (i) => fx.finde(zeilen()[i], '[data-staffel="ab"]') const preis = (i) => fx.finde(zeilen()[i], '[data-staffel="preis"]') await fx.setze(ab(1), '1') assert.equal(await versuche(fx, panel), 0) assert.match(fehlerText(fx, panel, 'staffeln'), /größer werden/) await fx.setze(ab(1), '7') await fx.setze(preis(1), '0,50') assert.equal(await versuche(fx, panel), 0) assert.match(fehlerText(fx, panel, 'staffeln'), /1,00 € und 1\.000,00 €/) await fx.setze(preis(1), '1.000,01') assert.equal(await versuche(fx, panel), 0) await fx.setze(preis(1), '50') for (let i = 0; i < 5; i += 1) { await fx.klick(aktion(fx, panel, 'staffel-dazu')); await fx.setze(ab(zeilen().length - 1), String(10 + i)); await fx.setze(preis(zeilen().length - 1), '40') } assert.equal(zeilen().length, 7) assert.equal(await versuche(fx, panel), 0) assert.match(fehlerText(fx, panel, 'staffeln'), /1 bis 6/) await fx.klick(fx.finde(zeilen()[6], '[data-aktion="staffel-weg"]')) assert.equal(await versuche(fx, panel), 1) })) test('Preis-Editor: Rabatt, Frist, Personen, Stornobedingungen, USt', mit(ukServer({ 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }) }), async (fx) => { const panel = await preisEditor(fx) const fall = async (sel, wert, ok, feldName, re) => { const alt = fx.finde(panel, sel).value await fx.setze(fx.finde(panel, sel), wert) assert.equal(await versuche(fx, panel), 0, `${sel}=${wert}`) assert.match(fehlerText(fx, panel, feldName), re) await fx.setze(fx.finde(panel, sel), alt) } await fall('#bk-p-rabatt', '51', 0, 'direktrabattProzent', /0 bis 50/) await fall('#bk-p-frist', '0', 0, 'fristTage', /1 bis 14/) await fall('#bk-p-frist', '15', 0, 'fristTage', /1 bis 14/) await fall('#bk-p-inklusive', '0', 0, 'personenInklusive', /ab 1/) await fall('#bk-p-storno', 'zu kurz', 0, 'stornobedingungen', /20 bis 5000/) await fall('#bk-p-ust-ue', '8', 0, 'ustUebernachtung', /0, 7 oder 19/) await fall('#bk-p-ust-re', '20', 0, 'ustReinigung', /0, 7 oder 19/) assert.equal(await versuche(fx, panel), 1) })) test('Preis-Editor: gespeicherte USt 19 bleibt bei Kleinunternehmer unverändert', mit(ukServer({ 'GET /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: { preise: { ...PREISE, steuerart: 'kleinunternehmer', ustUebernachtung: 19, ustReinigung: 0 } } }), 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }), }), async (fx) => { const panel = await preisEditor(fx) assert.equal(await versuche(fx, panel), 1) const put = posts(fx).find((a) => a.url.includes('/preise')) assert.equal(put.body.steuerart, 'kleinunternehmer') assert.equal(put.body.ustUebernachtung, 19) assert.equal(put.body.ustReinigung, 0) })) test('fehlerFuerFormular: deutsche Texte fuer die Fachcodes des Buchungskalenders', async () => { const { fehlerFuerFormular } = await import('../public/buchungen-fehler.js') const text = (fehler) => fehlerFuerFormular({ fehler, message: 'Fehler 409' }).message assert.equal(text('zustimmung_fehlt'), 'Bitte erteile zuerst in den Einstellungen die Zustimmung zur Rechnungsstellung in deinem Namen.') assert.equal(text('stripe_nicht_aktiv'), 'Online-Zahlung geht erst, wenn dein Stripe-Konto freigeschaltet ist.') assert.match(text('erstattung_fehlgeschlagen'), /Erstattung ist fehlgeschlagen/) assert.match(text('frist_abgelaufen'), /Frist .* abgelaufen/) assert.equal(text('zeitraum_belegt'), 'Der Zeitraum ist bereits belegt.') }) test('fehlerFuerFormular: unbekannter Code → deutscher Standardtext', async () => { const { fehlerFuerFormular } = await import('../public/buchungen-fehler.js') assert.equal(fehlerFuerFormular({ fehler: 'komischer_code', message: 'Fehler 400' }).fehler._, 'Das hat nicht geklappt. Bitte versuch es noch einmal.') assert.equal(fehlerFuerFormular({ fehler: 'zeitraum_belegt', message: 'x' }).fehler._, 'Der Zeitraum ist bereits belegt.') }) test('Kalender: vergangene Tage lassen sich nicht sperren', mit(ukServer(), async (fx) => { const { panel } = await zeigeTab(fx, kal) await fx.klick(tag(fx, panel, '2026-10-01')) assert.match(fx.text(fx.finde(panel, '[data-hinweis]')), /Vergangene Tage/) assert.equal(fx.finde(panel, '[data-aktion="sperren-senden"]'), null) })) /* ===================================================== Task 14: Rechnungen, Einstellungen, Anfrage ===================================================== */ const { render: renderRechnungen } = await import('../public/buchungen-tab-rechnungen.js') const { STORNO_MUSTER, STORNO_HINWEIS } = await import('../public/buchungen-preise.js') const { render: renderEinstellungen, AUFTRAG_TEXT, AUFTRAG_WIDERRUF } = await import('../public/buchungen-tab-einstellungen.js') const { renderAnfrageFormular, anfrageKarte } = await import('../public/buchungen-anfrage.js') const { initBuchungen, vergissAuswahl } = await import('../public/buchungen.js') const zeigeMit = async (fx, modul, ctxExtra = {}) => { const ctx = { ...baueCtx(), ...ctxExtra } const panel = h('div') fx.document.body.append(panel) await modul(panel, ctx) await fx.ruhe() return { panel, ctx } } const ES = { firma: 'Haus am See', inhaber: 'Mimi M.', strasse: 'Seeweg 1', plz: '78532', ort: 'Tuttlingen', email: 'mimi@example.com', telefon: '', steuernummer: '12/345/67890', ustId: '', iban: '', bic: '', bank: '', rechnungsPraefix: 'RE', akzentfarbe: '#336699', onlineZahlung: false, rechnungAuftrag: true } const esServer = (o = {}, antwort = {}) => server({ 'GET /api/unterkunft/einstellungen': () => ({ body: { einstellungen: ES, stripe: { status: 'keins', vorhandenesShopKonto: false }, rechnungenVorhanden: false, ...antwort } }), 'PUT /api/unterkunft/einstellungen': () => ({ body: {} }), ...o }) test('Rechnungen: Liste mit PDF-Link, Art und Betrag; Fremdtext nur als Text', mit(server({ 'GET /api/unterkunft/rechnungen': () => ({ body: { rechnungen: [ { id: 'r1', nummer: 'RE-2026-0001', datum: '2026-10-07', art: 'rechnung', gastName: 'Anna M', bruttoCent: 40650 }, { id: 'r2', nummer: 'RE-2026-0002', datum: '2026-10-08', art: 'storno', gastName: 'Anna', bruttoCent: -40650 }, ] } }), }), async (fx) => { const { panel } = await zeigeMit(fx, renderRechnungen) assert.equal(fx.fetchLog[0].url, '/api/unterkunft/rechnungen?projectId=p1') const z = fx.alle(panel, '[data-rechnung]') assert.equal(z.length, 2) assert.match(fx.text(z[0]), /RE-2026-0001/) assert.match(fx.text(z[0]), /07\.10\.2026/) assert.match(fx.text(z[0]), /Anna M<\/b>/) assert.match(fx.text(z[0]), /406,50\s€/) assert.match(fx.text(z[1]), /Storno-Rechnung/) assert.match(fx.text(z[1]), /−406,50\s€/) assert.equal(fx.finde(z[0], '[data-aktion="pdf"]').getAttribute('href'), '/api/unterkunft/rechnungen/r1.pdf?projectId=p1') assert.equal(fx.finde(z[0], 'b'), null) })) test('Rechnungen: leer → Hinweis', mit(server({ 'GET /api/unterkunft/rechnungen': () => ({ body: { rechnungen: [] } }) }), async (fx) => { const { panel } = await zeigeMit(fx, renderRechnungen) assert.match(fx.text(panel), /Noch keine Rechnungen/) assert.equal(fx.alle(panel, '[data-rechnung]').length, 0) })) test('Einstellungen: Pflichtfelder markiert, leeres Formular sendet nicht und zeigt Feldfehler', mit(esServer({}, { einstellungen: { ...ES, firma: '', steuernummer: '', ustId: '' } }), async (fx) => { const { panel } = await zeigeMit(fx, renderEinstellungen) for (const n of ['firma', 'inhaber', 'strasse', 'plz', 'ort', 'email']) assert.match(fx.text(fx.finde(panel, `[data-feld="${n}"]`).parentNode.querySelector('label')), /\*/, n) await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) await fx.ruhe() assert.equal(posts(fx).length, 0) assert.match(fx.text(fx.finde(panel, '[data-fehler="firma"]')), /Firma/) assert.match(fx.text(fx.finde(panel, '[data-fehler="steuernummer"]')), /Steuernummer oder USt-IdNr/) assert.match(fx.text(fx.finde(panel, '[data-fehler="ustId"]')), /Steuernummer oder USt-IdNr/) })) test('Einstellungen: speichern sendet die Felder ohne Wrapper; Serverfehler am Feld', mit(esServer({ 'PUT /api/unterkunft/einstellungen': () => ({ status: 400, body: { fehler: 'ungueltig', felder: { iban: 'Bitte gib eine gültige IBAN an.' } } }) }), async (fx) => { const { panel } = await zeigeMit(fx, renderEinstellungen) await fx.setze(fx.finde(panel, '[data-feld="iban"]'), 'xx') await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) await fx.ruhe() const put = posts(fx)[0] assert.equal(put.url, '/api/unterkunft/einstellungen?projectId=p1') assert.equal(put.body.firma, 'Haus am See') assert.equal(put.body.rechnungsPraefix, 'RE') assert.equal(put.body.rechnungAuftrag, true) assert.equal(put.body.iban, 'xx') assert.equal(put.body.onlineZahlung, false) assert.match(fx.text(fx.finde(panel, '[data-fehler="iban"]')), /gültige IBAN/) })) test('Einstellungen: Präfix prüfen; gesperrt mit Hinweis, sobald Rechnungen existieren', async () => { await mit(esServer(), async (fx) => { const { panel } = await zeigeMit(fx, renderEinstellungen) await fx.setze(fx.finde(panel, '[data-feld="rechnungsPraefix"]'), 'a') await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) await fx.ruhe() assert.equal(posts(fx).length, 0) assert.match(fx.text(fx.finde(panel, '[data-fehler="rechnungsPraefix"]')), /2 bis 8/) await fx.setze(fx.finde(panel, '[data-feld="rechnungsPraefix"]'), 'ab1') await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) await fx.ruhe() assert.equal(posts(fx)[0].body.rechnungsPraefix, 'AB1') })() await mit(esServer({}, { rechnungenVorhanden: true }), async (fx) => { const { panel } = await zeigeMit(fx, renderEinstellungen) const p = fx.finde(panel, '[data-feld="rechnungsPraefix"]') assert.ok(p.disabled) assert.match(fx.text(p.parentNode), /gesperrt/) await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) await fx.ruhe() assert.equal(posts(fx)[0].body.rechnungsPraefix, 'RE') })() }) test('Einstellungen: keine Stornobedingungen mehr; IBAN maxlength 34', mit(esServer(), async (fx) => { const { panel } = await zeigeMit(fx, renderEinstellungen) assert.equal(fx.finde(panel, '[data-feld="stornobedingungen"]'), null) assert.equal(fx.finde(panel, '[data-feld="iban"]').getAttribute('maxlength'), '34') await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) await fx.ruhe() assert.equal('stornobedingungen' in posts(fx)[0].body, false) })) test('Preis-Editor: Mustertext übernehmen füllt nur das Feld (kein Speichern), Hinweis „keine Rechtsberatung“', mit(ukServer(), async (fx) => { const panel = await preisEditor(fx) const t = fx.finde(panel, '#bk-p-storno') assert.match(fx.text(fx.finde(panel, '[data-hinweis="storno-muster"]')), /Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung\./) assert.equal(STORNO_HINWEIS, 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.') const vorher = posts(fx).length await fx.klick(fx.finde(panel, '[data-aktion="storno-muster"]')) assert.equal(t.value, STORNO_MUSTER) assert.match(STORNO_MUSTER, /^1\. Stornierung durch den Gast\./) assert.match(STORNO_MUSTER, /Reiserücktrittsversicherung\.$/) assert.ok(STORNO_MUSTER.length <= 5000, 'passt in das Feld (max. 5000 Zeichen)') // wortgleich zur Doku (maßgeblich), ohne Markdown-Fettdruck const doku = readFileSync(new URL('../docs/funktionen/unterkunft-storno-muster.md', import.meta.url), 'utf8').split('## Stornobedingungen (Muster)')[1].trim().replaceAll('**', '') assert.equal(STORNO_MUSTER, doku) assert.equal(posts(fx).length, vorher) })) test('Einstellungen: Zustimmung zurückziehen fragt nach; Abbrechen behält, Ja sendet rechnungAuftrag:false', mit(esServer(), async (fx) => { const { panel } = await zeigeMit(fx, renderEinstellungen) const box = fx.finde(panel, '[data-feld="rechnungAuftrag"]') await fx.klick(box) assert.ok(box.checked) assert.match(fx.text(panel), new RegExp(AUFTRAG_WIDERRUF.slice(0, 50))) await fx.klick(fx.finde(panel, '[data-aktion="widerruf-nein"]')) assert.ok(box.checked) assert.equal(fx.finde(panel, '[data-aktion="widerruf-ja"]'), null) await fx.klick(box) await fx.klick(fx.finde(panel, '[data-aktion="widerruf-ja"]')) assert.equal(box.checked, false) await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) await fx.ruhe() assert.equal(posts(fx)[0].body.rechnungAuftrag, false) })) test('Einstellungen: Auftrag zur Rechnungsstellung – ohne Haken Hinweis, mit Haken weg und rechnungAuftrag im PUT', mit(esServer({}, { einstellungen: { ...ES, rechnungAuftrag: false } }), async (fx) => { const { panel } = await zeigeMit(fx, renderEinstellungen) const box = fx.finde(panel, '[data-feld="rechnungAuftrag"]') assert.match(fx.text(box.parentNode), new RegExp(AUFTRAG_TEXT.slice(0, 40))) const hinweis = fx.finde(panel, '[data-hinweis="auftrag"]') assert.equal(hinweis.hidden, false) assert.match(fx.text(hinweis), /nicht bestätigen/) await fx.klick(box) assert.equal(hinweis.hidden, true) await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) await fx.ruhe() assert.equal(posts(fx)[0].body.rechnungAuftrag, true) })) test('Einstellungen: Stripe nicht verbunden – Verbinden leitet nur auf https; „Vorhandenes Webshop-Konto“ nur wenn gemeldet', async () => { await mit(esServer({ 'POST /api/unterkunft/stripe/verbinden': () => ({ body: { url: 'https://connect.stripe.com/x' } }) }), async (fx) => { const { panel } = await zeigeMit(fx, renderEinstellungen) assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'keins') assert.equal(fx.finde(panel, '[data-aktion="stripe-vorhanden"]'), null) assert.equal(fx.finde(panel, '[data-feld="onlineZahlung"]'), null) await fx.klick(fx.finde(panel, '[data-aktion="stripe-verbinden"]')) await fx.ruhe() assert.equal(posts(fx)[0].url, '/api/unterkunft/stripe/verbinden?projectId=p1') assert.equal(fx.window.location.href, 'https://connect.stripe.com/x') })() await mit(esServer({ 'POST /api/unterkunft/stripe/verbinden': () => ({ body: { url: 'javascript:alert(1)' } }) }), async (fx) => { const { panel, ctx } = await zeigeMit(fx, renderEinstellungen) await fx.klick(fx.finde(panel, '[data-aktion="stripe-verbinden"]')) await fx.ruhe() assert.notEqual(fx.window.location.href, 'javascript:alert(1)') assert.equal(ctx.meldungen.at(-1).art, 'fehler') })() await mit(esServer({ 'POST /api/unterkunft/stripe/vorhandenes-konto': () => ({ body: { stripe: { status: 'aktiv' } } }) }, { stripe: { status: 'keins', vorhandenesShopKonto: true } }), async (fx) => { const { panel } = await zeigeMit(fx, renderEinstellungen) await fx.klick(fx.finde(panel, '[data-aktion="stripe-vorhanden"]')) await fx.ruhe() assert.equal(posts(fx)[0].url, '/api/unterkunft/stripe/vorhandenes-konto?projectId=p1') assert.ok(fx.fetchLog.filter((a) => a.method === 'GET' && a.url.startsWith('/api/unterkunft/einstellungen')).length >= 2) // neu geladen })() }) test('Einstellungen: Stripe Onboarding offen und aktiv; Schalter Online-Zahlung nur bei aktiv', async () => { await mit(esServer({ 'POST /api/unterkunft/stripe/aktualisieren': () => ({ body: { stripe: { status: 'aktiv' } } }) }, { stripe: { status: 'onboarding', vorhandenesShopKonto: true } }), async (fx) => { const { panel } = await zeigeMit(fx, renderEinstellungen) assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'onboarding') assert.match(fx.text(panel), /Angaben bei Stripe/) assert.ok(fx.finde(panel, '[data-aktion="stripe-aktualisieren"]')) assert.equal(fx.finde(panel, '[data-aktion="stripe-vorhanden"]'), null) assert.equal(fx.finde(panel, '[data-feld="onlineZahlung"]'), null) })() await mit(esServer({}, { einstellungen: { ...ES, onlineZahlung: true }, stripe: { status: 'aktiv', vorhandenesShopKonto: false } }), async (fx) => { const { panel } = await zeigeMit(fx, renderEinstellungen) assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'aktiv') const schalter = fx.finde(panel, '[data-feld="onlineZahlung"]') assert.ok(schalter.checked) await fx.klick(schalter) await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) await fx.ruhe() assert.equal(posts(fx)[0].body.onlineZahlung, false) })() }) test('Einstellungen: nur lesen sperrt Formular und Stripe-Knöpfe', mit(esServer(), async (fx) => { const { panel } = await zeigeMit(fx, renderEinstellungen, { nurLesen: true }) assert.equal(fx.finde(panel, '[data-aktion="stripe-verbinden"]'), null) assert.ok(fx.finde(panel, '[data-feld="firma"]').disabled) assert.equal(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'), null) })) test('Anfrageformular sendet { art, einheiten, portale, nachricht }; Feldfehler am Feld', async () => { let antwort = { body: { anfrage: { status: 'offen' } } } await mit((a) => (a.method === 'POST' ? antwort : { body: {} }), async (fx) => { const wurzel = h('div') fx.document.body.append(wurzel) let danach = 0 renderAnfrageFormular(wurzel, { projectId: 'p1', nachSenden: async () => { danach += 1 } }) antwort = { status: 400, body: { fehler: 'ungueltig', felder: { art: 'Bitte wähle die Art deiner Unterkunft.' } } } await fx.klick(fx.finde(wurzel, '[data-aktion="anfrage-senden"]')) await fx.ruhe() assert.match(fx.text(fx.finde(wurzel, '[data-fehler="art"]')), /Art deiner Unterkunft/) assert.equal(danach, 0) antwort = { body: { anfrage: { status: 'offen' } } } await fx.setze(fx.finde(wurzel, '[data-feld="art"]'), 'ferienwohnung') await fx.setze(fx.finde(wurzel, '[data-feld="einheiten"]'), '3') await fx.klick(fx.finde(wurzel, '[data-feld="portale.airbnb"]')) await fx.setze(fx.finde(wurzel, '[data-feld="nachricht"]'), ' Hallo ') await fx.klick(fx.finde(wurzel, '[data-aktion="anfrage-senden"]')) await fx.ruhe() const p = posts(fx).at(-1) assert.equal(p.url, '/api/unterkunft/anfrage-funktion?projectId=p1') assert.deepEqual(p.body, { art: 'ferienwohnung', einheiten: 3, portale: ['airbnb'], nachricht: 'Hallo' }) assert.equal(danach, 1) assert.match(fx.text(wurzel), /199 €/) })() }) test('Anfragekarte: offen mit Zurückziehen, abgelehnt mit Begründung als Text', async () => { await mit(server(), async (fx) => { let neuGeladen = 0 const k = anfrageKarte({ status: 'offen', at: '2026-10-07T10:00:00Z', anfrage: { art: 'monteur', einheiten: 2, portale: ['booking'], nachricht: 'x' } }, { projectId: 'p1', neuLaden: async () => { neuGeladen += 1 } }) fx.document.body.append(k) assert.match(fx.text(k), /In Prüfung/) assert.match(fx.text(k), /Monteurwohnung/) assert.match(fx.text(k), /Booking\.com/) await fx.klick(fx.finde(k, '[data-aktion="zurueckziehen"]')) await fx.ruhe() assert.equal(posts(fx)[0].url, '/api/unterkunft/anfrage-funktion/zurueckziehen?projectId=p1') assert.equal(neuGeladen, 1) const b = anfrageKarte({ status: 'abgelehnt', begruendung: 'Passt nicht' }, { projectId: 'p1', neu: () => {} }) assert.match(fx.text(b), /Passt nicht<\/i>/) assert.equal(fx.finde(b, 'i'), null) assert.ok(fx.finde(b, '[data-aktion="neue-anfrage"]')) })() }) test('Anfrageformular sendet monteurzimmer; ein vorhandenes monteur wird als Monteurwohnung gewaehlt', async () => { await mit(() => ({ body: { anfrage: { status: 'offen' } } }), async (fx) => { const wurzel = h('div') fx.document.body.append(wurzel) renderAnfrageFormular(wurzel, { projectId: 'p1', vorgabe: { art: 'monteur', einheiten: 2, portale: [], nachricht: '' }, nachSenden: async () => {} }) assert.equal(fx.finde(wurzel, '[data-feld="art"]').value, 'monteurzimmer') await fx.setze(fx.finde(wurzel, '[data-feld="einheiten"]'), '2') await fx.klick(fx.finde(wurzel, '[data-aktion="anfrage-senden"]')) await fx.ruhe() assert.equal(posts(fx).at(-1).body.art, 'monteurzimmer') })() }) test('Zurückziehen 409: Stand wird mit der Meldung neu geladen', async () => { await mit((a) => (a.url.includes('zurueckziehen') ? { status: 409, body: { fehler: 'status_ungueltig', error: 'Die Anfrage wurde schon bearbeitet.' } } : { body: {} }), async (fx) => { const gesehen = [] const k = anfrageKarte({ status: 'offen', at: '2026-10-07T10:00:00Z', anfrage: { art: 'pension' } }, { projectId: 'p1', neuLaden: async (o) => { gesehen.push(o) } }) fx.document.body.append(k) await fx.klick(fx.finde(k, '[data-aktion="zurueckziehen"]')) await fx.ruhe() assert.equal(gesehen.length, 1) assert.equal(typeof gesehen[0]?.meldung, 'string') assert.ok(gesehen[0].meldung.length > 0) })() }) test('Ansicht #buchungen: Zustand off zeigt das Anfrageformular, Projekt vom Wunsch-Attribut', async () => { vergissAuswahl() await mit((a) => { if (a.url === '/api/funktionen') return { body: { unterkunft: { verfuegbar: true }, projects: [{ projectId: 'p1', projectName: 'A', features: { unterkunft: { state: 'off' } } }, { projectId: 'p2', projectName: 'B', features: { unterkunft: { state: 'off' } } }] } } if (a.url.startsWith('/api/unterkunft/status')) return { body: { state: 'off' } } return { body: {} } }, async (fx) => { const root = h('div') root.dataset.wunschProjekt = 'p2' fx.document.body.append(root) await initBuchungen(root) await fx.ruhe() assert.equal(fx.fetchLog[1].url, '/api/unterkunft/status?projectId=p2') assert.ok(fx.finde(root, '[data-aktion="anfrage-senden"]')) assert.match(fx.text(root), /Buchungskalender anfragen/) })() vergissAuswahl() }) test('Ansicht #buchungen: Zustand ordered zeigt „Zahlung offen“ mit Zahlung fortsetzen (Checkout unterkunft)', async () => { vergissAuswahl() await mit((a) => { if (a.url === '/api/funktionen') return { body: { unterkunft: { verfuegbar: true }, projects: [{ projectId: 'p1', projectName: 'A', features: { unterkunft: { state: 'ordered' } } }] } } if (a.url.startsWith('/api/unterkunft/status')) return { body: { state: 'ordered' } } if (a.url === '/api/funktionen/unterkunft/checkout') return { status: 409, body: { error: 'Bitte stimme zu.', code: 'andere' } } return { body: {} } }, async (fx) => { const root = h('div') fx.document.body.append(root) await initBuchungen(root) await fx.ruhe() assert.match(fx.text(root), /Zahlung offen/) await fx.klick(fx.finde(root, '[data-aktion="fortsetzen"]')) await fx.ruhe() assert.ok(posts(fx).some((a) => a.url === '/api/funktionen/unterkunft/checkout')) })() vergissAuswahl() }) test('Anfrageformular: 409 lädt die Ansicht neu, die Meldung bleibt sichtbar; Projektwechsel setzt „neue Anfrage“ zurück', async () => { vergissAuswahl() let status = { state: 'off', anfrage: { status: 'abgelehnt', begruendung: 'Nein' } } await mit((a) => { if (a.url === '/api/funktionen') return { body: { unterkunft: { verfuegbar: true }, projects: [{ projectId: 'p1', projectName: 'A', features: { unterkunft: { state: 'off' } } }, { projectId: 'p2', projectName: 'B', features: { unterkunft: { state: 'off' } } }] } } if (a.url.startsWith('/api/unterkunft/status')) return { body: status } if (a.method === 'POST') { status = { state: 'off', anfrage: { status: 'offen', anfrage: {} } }; return { status: 409, body: { fehler: 'anfrage_laeuft' } } } return { body: {} } }, async (fx) => { const root = h('div') fx.document.body.append(root) await initBuchungen(root) await fx.ruhe() await fx.klick(fx.finde(root, '[data-aktion="neue-anfrage"]')) await fx.klick(fx.finde(root, '[data-aktion="anfrage-senden"]')) await fx.ruhe() assert.ok(fx.finde(root, '[data-anfrage="offen"]')) assert.match(fx.text(fx.finde(root, '.sh-meldung')), /nicht geklappt/) status = { state: 'off', anfrage: { status: 'abgelehnt', begruendung: 'Nein' } } await fx.setze(fx.finde(root, '#buchungen-project'), 'p2') await fx.ruhe() assert.ok(fx.finde(root, '[data-anfrage="abgelehnt"]')) })() vergissAuswahl() }) test('CSS: ein ausgeblendetes .sh-box (hidden) bleibt unsichtbar (Hinweis zur Zustimmung in den Einstellungen)', () => { const css = readFileSync(new URL('../public/shop.css', import.meta.url), 'utf8') assert.match(css, /\.sh-box\[hidden\]\s*\{\s*display:\s*none/) }) test('Preis-Editor: Stornobedingungen mit [Platzhalter] werden nicht gespeichert (Mustertext unveraendert nicht speicherbar)', mit(ukServer({ 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }) }), async (fx) => { const panel = await preisEditor(fx) await fx.klick(aktion(fx, panel, 'storno-muster')) assert.equal(await versuche(fx, panel), 0) assert.equal(fehlerText(fx, panel, 'stornobedingungen'), 'Bitte ersetze die Platzhalter in eckigen Klammern durch deine Werte.') await fx.setze(fx.finde(panel, '#bk-p-storno'), 'Bis 30 Tage vor Anreise kostenfrei, danach 50 Prozent.') assert.equal(await versuche(fx, panel), 1) })) test('Posten-Editor: die Summenanzeige enthaelt bei Regelbesteuerung kein Text-"null"', () => { const fx = installFakeDom({ antworten: [] }) try { const e = baueEditor([{ text: 'Übernachtung', menge: 1, einzelCent: 10000, ustProzent: 7 }], 'regel') assert.doesNotMatch(fx.text(e.el), /null/) } finally { fx.restore() } }) // Abschluss F3: jeder Servercode hat einen deutschen Du-Satz mit Handlungshinweis test('Fehlertexte: alle Codes des Portals sind übersetzt (Du-Form, kein Standardtext)', async () => { const { fehlerFuerFormular } = await import('../public/buchungen-fehler.js') const standard = fehlerFuerFormular({ fehler: 'gibt_es_nicht' }).message for (const code of ['preise_unvollstaendig', 'einstellungen_unvollstaendig', 'status_ungueltig', 'limit_erreicht', 'kein_konto', 'nicht_aktiv', 'praefix_gesperrt', 'sperre_aus_ical', 'online_aus', 'zahlung_fehlgeschlagen']) { const m = fehlerFuerFormular({ fehler: code }).message assert.notEqual(m, standard, code) assert.ok(m.length > 30 && m.endsWith('.'), code) assert.doesNotMatch(m, /\bSie\b|\bIhre?\b/, code) } }) test('Preis-Editor: Platzhalter-Feldfehler verschwindet beim Tippen', mit(ukServer(), async (fx) => { const { panel } = await zeigeTab(fx, ukTab) await fx.klick(aktion(fx, panel, 'bearbeiten')) await fx.ruhe() const storno = fx.finde(panel, '#bk-p-storno') await fx.setze(storno, 'Bis zu [X] Tage vor Anreise kostenfrei stornierbar, danach 100 %.') await fx.klick(aktion(fx, panel, 'preise-speichern')) await fx.ruhe() assert.match(fehlerText(fx, panel, 'stornobedingungen'), /Platzhalter/) await fx.setze(storno, 'Bis zu 14 Tage vor Anreise kostenfrei stornierbar, danach 100 %.') assert.equal(fehlerText(fx, panel, 'stornobedingungen'), '') })) // ---- Preis-Editor: Preise je Personenzahl (staffeln[].personenCent), Haken #bk-p-personenmodus const PERSONEN_PREISE = { ...PREISE, staffeln: [ { abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] }, { abNaechten: 7, preisProNachtCent: 5000, personenCent: [5000, 6500, 7700] }, ], } const PUT_PREISE = { 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }) } /** Server mit anderen Preisen und/oder anderer Gaestezahl der Unterkunft. */ const personenServer = (preise, maxPersonen = 3, o = {}) => ukServer({ 'GET /api/unterkunft/unterkuenfte': () => ({ body: { unterkuenfte: [{ ...UK, maxPersonen }] } }), 'GET /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: { preise } }), ...PUT_PREISE, ...o, }) const zeilen = (fx, panel) => fx.alle(panel, '[data-staffel-zeile]') const spalten = (fx, zeile) => fx.alle(zeile, '[data-staffel="person"]') const putPreise = (fx) => posts(fx).filter((a) => a.url.includes('/preise')).at(-1) const modusBox = (fx, panel) => fx.finde(panel, '#bk-p-personenmodus') const sichtbar = (el) => { for (let e = el; e && e.hidden !== undefined; e = e.parentElement) if (e.hidden) return false; return true } test('Preis-Editor Personen-Modus: gespeicherte personenCent laden (Haken an, k Felder je Staffel), Speichern sendet sie unverändert', mit(personenServer(PERSONEN_PREISE), async (fx) => { const panel = await preisEditor(fx) assert.equal(modusBox(fx, panel).checked, true) assert.equal(zeilen(fx, panel).length, 2) for (const [i, erwartet] of [['5500', '7000', '8200'], ['5000', '6500', '7700']].entries()) { const felder = spalten(fx, zeilen(fx, panel)[i]) assert.equal(felder.length, 3) assert.deepEqual(felder.map((f) => f.value), erwartet.map((c) => `${c.slice(0, -2)},${c.slice(-2)}`)) assert.deepEqual(felder.map((f) => f.dataset.personen), ['1', '2', '3']) assert.ok(felder.every(sichtbar)) assert.equal(sichtbar(fx.finde(zeilen(fx, panel)[i], '[data-staffel="preis"]')), false, 'der eine Preis ist ausgeblendet') } const beschriftung = fx.text(zeilen(fx, panel)[0]) assert.match(beschriftung, /1 Person(?!en)/) assert.match(beschriftung, /2 Personen/) assert.match(beschriftung, /3 Personen/) assert.equal(sichtbar(fx.finde(panel, '[data-hinweis="personenpreise"]')), true) assert.equal(fx.text(fx.finde(panel, '[data-hinweis="personenpreise"]')), 'Preis pro Nacht für die ganze Unterkunft, je nach Anzahl der Gäste.') assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), false) assert.equal(sichtbar(fx.finde(panel, '#bk-p-aufpreis')), false) assert.equal(await versuche(fx, panel), 1) const put = putPreise(fx) assert.deepEqual(put.body.staffeln, PERSONEN_PREISE.staffeln) assert.equal(put.body.personenInklusive, 2, 'ausgeblendete Felder gehen unverändert mit') assert.equal(put.body.aufpreisPersonNachtCent, 1000) assert.equal(put.body.reinigungCent, 6000) })) test('Preis-Editor Personen-Modus: preisProNachtCent im Body ist personenCent[0]; Felder lassen sich ändern', mit(personenServer(PERSONEN_PREISE), async (fx) => { const panel = await preisEditor(fx) await fx.setze(spalten(fx, zeilen(fx, panel)[1])[0], '48,50') await fx.setze(spalten(fx, zeilen(fx, panel)[1])[2], '75') assert.equal(await versuche(fx, panel), 1) assert.deepEqual(putPreise(fx).body.staffeln[1], { abNaechten: 7, preisProNachtCent: 4850, personenCent: [4850, 6500, 7500] }) })) test('Preis-Editor Personen-Modus: Haken schaltet vom alten Modell um (k Felder aus maxPersonen, Hilfsfelder weg) und zurück', mit(personenServer(PREISE, 4), async (fx) => { const panel = await preisEditor(fx) const box = modusBox(fx, panel) assert.equal(box.checked, false) assert.equal(sichtbar(box), true) assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), true) assert.equal(sichtbar(fx.finde(panel, '#bk-p-aufpreis')), true) assert.equal(sichtbar(fx.finde(panel, '[data-hinweis="personenpreise"]')), false) assert.ok(zeilen(fx, panel).every((z) => sichtbar(fx.finde(z, '[data-staffel="preis"]')) && spalten(fx, z).every((f) => !sichtbar(f)))) await fx.klick(box) assert.equal(box.checked, true) for (const z of zeilen(fx, panel)) { assert.equal(spalten(fx, z).length, 4, 'k = maxPersonen') assert.ok(spalten(fx, z).every((f) => sichtbar(f) && f.value === ''), 'fehlende Werte bleiben leer') assert.equal(sichtbar(fx.finde(z, '[data-staffel="preis"]')), false) } assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), false) assert.equal(sichtbar(fx.finde(panel, '#bk-p-aufpreis')), false) assert.equal(sichtbar(fx.finde(panel, '[data-hinweis="personenpreise"]')), true) // neue Staffel hat die Felder ebenfalls await fx.klick(aktion(fx, panel, 'staffel-dazu')) assert.equal(spalten(fx, zeilen(fx, panel)[2]).length, 4) assert.ok(spalten(fx, zeilen(fx, panel)[2]).every(sichtbar)) await fx.klick(fx.finde(zeilen(fx, panel)[2], '[data-aktion="staffel-weg"]')) // zurück: alter Modus wie bisher, der eine Preis blieb erhalten await fx.klick(box) assert.equal(box.checked, false) assert.equal(fx.finde(zeilen(fx, panel)[0], '[data-staffel="preis"]').value, '55,00') assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), true) assert.equal(await versuche(fx, panel), 1) assert.deepEqual(putPreise(fx).body.staffeln, PREISE.staffeln) assert.ok(putPreise(fx).body.staffeln.every((s) => !('personenCent' in s))) })) test('Preis-Editor Personen-Modus: Umschalten, alle Felder füllen, Speichern', mit(personenServer(PREISE, 3), async (fx) => { const panel = await preisEditor(fx) await fx.klick(modusBox(fx, panel)) assert.equal(await versuche(fx, panel), 0, 'leere Felder werden nicht gesendet') assert.match(fehlerText(fx, panel, 'staffeln'), /für jede Personenzahl einen Preis/) const werte = [['55', '70', '82'], ['50', '65', '77']] for (const [i, z] of zeilen(fx, panel).entries()) for (const [j, f] of spalten(fx, z).entries()) await fx.setze(f, werte[i][j]) assert.equal(await versuche(fx, panel), 1) assert.deepEqual(putPreise(fx).body.staffeln, [ { abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] }, { abNaechten: 7, preisProNachtCent: 5000, personenCent: [5000, 6500, 7700] }, ]) assert.equal(putPreise(fx).body.personenInklusive, 2) assert.equal(putPreise(fx).body.aufpreisPersonNachtCent, 1000) })) test('Preis-Editor Personen-Modus: Client prüft Preisgrenzen und Staffeln wie der Server', mit(personenServer(PERSONEN_PREISE), async (fx) => { const panel = await preisEditor(fx) const zeile = (i) => zeilen(fx, panel)[i] await fx.setze(spalten(fx, zeile(0))[1], '0,50') assert.equal(await versuche(fx, panel), 0) assert.match(fehlerText(fx, panel, 'staffeln'), /1,00 € und 1\.000,00 €/) await fx.setze(spalten(fx, zeile(0))[1], '1.000,01') assert.equal(await versuche(fx, panel), 0) await fx.setze(spalten(fx, zeile(0))[1], 'abc') assert.equal(await versuche(fx, panel), 0) assert.match(fehlerText(fx, panel, 'staffeln'), /für jede Personenzahl einen Preis/) await fx.setze(spalten(fx, zeile(0))[1], '70') await fx.setze(fx.finde(zeile(1), '[data-staffel="ab"]'), '1') assert.equal(await versuche(fx, panel), 0) assert.match(fehlerText(fx, panel, 'staffeln'), /größer werden/) await fx.setze(fx.finde(zeile(1), '[data-staffel="ab"]'), '7') assert.equal(await versuche(fx, panel), 1) })) test('Preis-Editor Personen-Modus: weniger gespeicherte Werte als Felder bleiben leer, überzählige werden verworfen', async () => { const gespeichert = { ...PERSONEN_PREISE, staffeln: [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] }] } // Unterkunft jetzt für 4 Gäste: vierte Spalte leer await mit(personenServer(gespeichert, 4), async (fx) => { const panel = await preisEditor(fx) assert.deepEqual(spalten(fx, zeilen(fx, panel)[0]).map((f) => f.value), ['55,00', '70,00', '82,00', '']) assert.equal(await versuche(fx, panel), 0) assert.match(fehlerText(fx, panel, 'staffeln'), /für jede Personenzahl einen Preis/) })() // Unterkunft jetzt für 2 Gäste: dritter Wert fällt weg await mit(personenServer(gespeichert, 2), async (fx) => { const panel = await preisEditor(fx) assert.deepEqual(spalten(fx, zeilen(fx, panel)[0]).map((f) => f.value), ['55,00', '70,00']) assert.equal(await versuche(fx, panel), 1) assert.deepEqual(putPreise(fx).body.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000] }]) })() // mehr als 10 Gäste: höchstens 10 Felder await mit(personenServer(gespeichert, 15), async (fx) => { const panel = await preisEditor(fx) assert.equal(spalten(fx, zeilen(fx, panel)[0]).length, 10) assert.match(fx.text(zeilen(fx, panel)[0]), /10 Personen/) })() }) test('Preis-Editor Personen-Modus: bei einer Person Höchstzahl kein Haken, altes Modell, personenCent fällt weg', mit(personenServer(PERSONEN_PREISE, 1), async (fx) => { const panel = await preisEditor(fx) const box = modusBox(fx, panel) assert.equal(box.checked, false) assert.equal(sichtbar(box), false) assert.ok(zeilen(fx, panel).every((z) => spalten(fx, z).length === 0 && sichtbar(fx.finde(z, '[data-staffel="preis"]')))) assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), true) assert.equal(await versuche(fx, panel), 1) assert.deepEqual(putPreise(fx).body.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500 }, { abNaechten: 7, preisProNachtCent: 5000 }]) })) test('Preis-Editor Personen-Modus: nur lesen sperrt Haken und alle Felder', async () => { await mit(personenServer(PERSONEN_PREISE), async (fx) => { const { panel } = await zeigeMit(fx, ukTab.render, { nurLesen: true }) await fx.klick(aktion(fx, panel, 'bearbeiten')) await fx.ruhe() assert.ok(modusBox(fx, panel).disabled) assert.equal(spalten(fx, zeilen(fx, panel)[0]).length, 3) assert.ok(fx.alle(panel, '.bk-preisform input, .bk-preisform select, .bk-preisform textarea, .bk-preisform button').every((el) => el.disabled), 'alles gesperrt') assert.equal(aktion(fx, panel, 'preise-speichern'), null) // Klick auf den gesperrten Haken ändert nichts await fx.klick(modusBox(fx, panel)) assert.equal(modusBox(fx, panel).checked, true) })() // auch im alten Modell sperrt nur lesen den Haken await mit(personenServer(PREISE, 3), async (fx) => { const { panel } = await zeigeMit(fx, ukTab.render, { nurLesen: true }) await fx.klick(aktion(fx, panel, 'bearbeiten')) await fx.ruhe() assert.ok(modusBox(fx, panel).disabled) await fx.klick(modusBox(fx, panel)) assert.equal(modusBox(fx, panel).checked, false) assert.ok(zeilen(fx, panel).every((z) => spalten(fx, z).every((f) => f.disabled))) })() }) test('Preis-Editor Personen-Modus: unlesbare Reste in den ausgeblendeten Feldern blockieren das Speichern nicht', mit(personenServer(PERSONEN_PREISE), async (fx) => { const panel = await preisEditor(fx) await fx.setze(fx.finde(panel, '#bk-p-inklusive'), 'xyz') await fx.setze(fx.finde(panel, '#bk-p-aufpreis'), 'abc') assert.equal(await versuche(fx, panel), 1) assert.equal(putPreise(fx).body.personenInklusive, 2) assert.equal(putPreise(fx).body.aufpreisPersonNachtCent, 1000) })) test('CSS: Felder je Personenzahl brechen um, ausgeblendete Teile bleiben unsichtbar', () => { const css = readFileSync(new URL('../public/buchungen.css', import.meta.url), 'utf8') assert.match(css, /\.bk-personenpreise\s*\{[^}]*flex-wrap:\s*wrap/) assert.match(css, /\.bk-personenpreise\[hidden\][^{]*\{\s*display:\s*none/) assert.match(css, /\.bk-preis-einzeln\[hidden\]/) })