diff --git a/public/funktion-bestellen.js b/public/funktion-bestellen.js index 8dfcad5..961b20d 100644 --- a/public/funktion-bestellen.js +++ b/public/funktion-bestellen.js @@ -84,13 +84,32 @@ const offeneListe = (wert) => (Array.isArray(wert) ? wert.filter((punkt) => punk const chatKnopf = () => h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen') +/* ----------------------------------------------- Rückkehr von der Zahlungsseite --- */ +/** + * Der Browser-Zurück-Knopf der Zahlungsseite stellt die Seite oft aus dem Speicher (bfcache) so wieder her, wie sie verlassen wurde: der Knopf bleibt + * gesperrt, es steht keine Meldung da. Darum laden Bestellseite und Statuskarte bei pageshow mit persisted neu (wie app.js beim Wechsel-Knopf). + * Gilt nur, solange das Element noch im Baum steht; danach entfernt sich der Handler beim nächsten pageshow selbst. + */ +function ladeBeiRueckkehrNeu(element) { + if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return + const beiAnzeige = (ev) => { + if (!element.isConnected) { + window.removeEventListener('pageshow', beiAnzeige) + return + } + if (ev?.persisted === true) window.location.reload() + } + window.addEventListener('pageshow', beiAnzeige) +} + /* ---------------------------------------------------------------- Bestellseite --- */ /** * „Prüfen und bestellen“ in root zeichnen und die Seite zurückgeben. Reihenfolge: Zusammenfassung der Angaben (je Gruppe „Ändern“), * offene Punkte (je Punkt „Zum Schritt“), Preis und Einrichtungsdauer, die vollständige Widerrufsbelehrung samt Muster-Formular als Text * (ein scrollbarer Bereich, nie eingeklappt), die Unterlagen als Links, die Zustimmungen und zuletzt der Knopf. * ctx = Kontext der Ansicht (projectId, aktuell(), neuLaden()), daten = Antwort von GET /api/termine/einstellungen im Zustand off - * (einstellung, fragebogen, branchen, offen, kundenansicht, bestellung), zumSchritt(key) springt in einen Schritt des Fragebogens. + * (einstellung, fragebogen, branchen, offen, kundenansicht, bestellung), zumSchritt(key) springt in einen Schritt des Fragebogens (ist es keine Funktion, + * zeichnet die Seite weder „Ändern“ noch „Zum Schritt“). * Der Knopf ist gesperrt, solange ein Haken fehlt, offene Punkte bleiben oder die Kundenansicht läuft. Ein Klick sperrt ihn, sendet * bestellKoerper und geht zur Zahlungsseite. Bei 409 lädt die Seite die Daten neu und setzt die Haken zurück (bestellung_offen zeigt die * Punkte aus der Antwort, fassung_veraltet die neue Fassung aus der Antwort, ein Abo-Code den Text der Antwort). @@ -116,15 +135,19 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) { const gewaehlt = new Set() let laeuft = false - const gehe = (schritt) => { if (typeof zumSchritt === 'function') zumSchritt(schritt) } + // „Ändern“ und „Zum Schritt“ gibt es nur, wo es ein Ziel gibt: ohne zumSchritt zeichnet die Seite keine Knöpfe, die nichts tun + const springt = typeof zumSchritt === 'function' + const gehe = (schritt) => { if (springt) zumSchritt(schritt) } // 1 Zusammenfassung: je Gruppe die Angaben als Text und „Ändern“ const gruppen = zusammenfassung(daten.einstellung, daten.fragebogen, daten.branchen).map((gruppe) => h('div', { class: 'tm-bestellen-gruppe', 'data-schritt': gruppe.schritt }, h('div', { class: 'tm-bestellen-gruppe-kopf' }, h('h4', null, gruppe.titel), - h('button', { - type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'aendern', 'data-schritt': gruppe.schritt, 'aria-label': `Ändern: ${gruppe.titel}`, onclick: () => gehe(gruppe.schritt), - }, 'Ändern')), + springt + ? h('button', { + type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'aendern', 'data-schritt': gruppe.schritt, 'aria-label': `Ändern: ${gruppe.titel}`, onclick: () => gehe(gruppe.schritt), + }, 'Ändern') + : null), gruppe.zeilen.length > 0 ? h('ul', null, gruppe.zeilen.map((zeile) => h('li', null, zeile))) : h('p', { class: 'tm-hinweis' }, 'Noch keine Angaben.'))) // 2 offene Punkte: „Zum Schritt“ nur dort, wo es einen Schritt des Fragebogens gibt (der Abo-Punkt gehört zu dieser Seite) @@ -133,7 +156,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) { h('p', { class: 'tm-hinweis' }, 'Erledige diese Punkte, dann kannst du bestellen.'), h('ul', null, offen.map((punkt) => h('li', null, h('span', null, punkt.text), - SCHRITT_TITEL.has(punkt.schritt) + springt && SCHRITT_TITEL.has(punkt.schritt) ? h('button', { type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'zum-schritt', 'data-schritt': punkt.schritt, 'aria-label': `Zum Schritt „${SCHRITT_TITEL.get(punkt.schritt)}“`, onclick: () => gehe(punkt.schritt), }, 'Zum Schritt') @@ -160,7 +183,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) { // 6 Zustimmungen in Registerreihenfolge: Einträge mit haken: true sind Haken, die anderen reiner Text const knopf = h('button', { type: 'button', class: 'kb-btn-primary tm-bestellen-knopf', 'data-aktion': 'bestellen', disabled: true, onclick: () => bestelle() }, text(bestellung.consent.knopf)) - const meldungEl = h('p', { class: 'tm-fehler tm-bestellen-meldung', role: 'alert', 'data-meldung': 'bestellen' }) + const meldungEl = h('p', { class: 'tm-fehler tm-bestellen-meldung', role: 'alert', 'data-meldung': 'bestellen', tabindex: '-1' }) const hinweisEl = h('p', { class: 'tm-hinweis', 'data-hinweis': 'bestellen' }) const hakenZeile = (eintrag) => { const feld = h('input', { type: 'checkbox', 'data-haken': eintrag.id }) @@ -186,6 +209,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) { zustimmungBlock, h('div', { class: 'tm-bestellen-aktion' }, meldungEl, knopf, hinweisEl)) root.replaceChildren(seite) + ladeBeiRueckkehrNeu(seite) function zeigeMeldung(meldung) { meldungEl.textContent = meldung @@ -210,13 +234,16 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) { } if (neu === null || typeof neu !== 'object') { zeigeMeldung(err.message) + meldungEl.focus({ preventScroll: true }) laeuft = false aktualisiere() return } if (typeof ctx.aktuell === 'function' && !ctx.aktuell()) return if (!neu.bestellung) { - root.replaceChildren(h('div', { class: 'kb-panel tm-info', role: 'status' }, h('p', null, err.message), h('p', null, 'Lade die Seite neu, dann siehst du den aktuellen Stand.'))) + const karte = h('div', { class: 'kb-panel tm-info', role: 'status', tabindex: '-1' }, h('p', null, err.message), h('p', null, 'Lade die Seite neu, dann siehst du den aktuellen Stand.')) + root.replaceChildren(karte) + karte.focus({ preventScroll: true }) return } const neueSeite = baueSeite(root, { ctx, daten: neu, zumSchritt }, { @@ -226,6 +253,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) { const neueMeldung = neueSeite.querySelector('[data-meldung="bestellen"]') if (neueMeldung) { neueMeldung.textContent = err.message + neueMeldung.focus({ preventScroll: true }) neueMeldung.scrollIntoView({ block: 'nearest' }) } } @@ -310,7 +338,7 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans fortsetzenKnopf.disabled = false } - return h('section', { class: 'kb-panel tm-statuskarte', 'data-state': bestellt ? 'ordered' : 'setup' }, + const karte = h('section', { class: 'kb-panel tm-statuskarte', 'data-state': bestellt ? 'ordered' : 'setup' }, danke ? h('p', { class: 'tm-danke', role: 'status', 'data-danke': true }, DANKE) : null, h('span', { class: 'tm-pille tm-pille-wait' }, bestellt ? 'Zahlung offen' : 'In Einrichtung'), h('h3', null, bestellt ? 'Deine Bestellung wartet auf die Zahlung' : 'Wir richten deinen Terminplaner ein'), @@ -325,4 +353,6 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans fortsetzenKnopf, !bestellt && rechnung ? h('a', { class: 'kb-btn-outline', href: sicherLink(rechnung), target: '_blank', rel: 'noopener noreferrer' }, 'Rechnung ansehen') : null, chatKnopf())) + ladeBeiRueckkehrNeu(karte) + return karte } diff --git a/public/termine-view.js b/public/termine-view.js index e399750..d2b9875 100644 --- a/public/termine-view.js +++ b/public/termine-view.js @@ -655,6 +655,7 @@ export function zusammenfassung(einstellung, fragebogen, branchen) { zeile('branche', istText(branche?.name) && `Branche: ${branche.name}`) zeile('branche', istText(betrieb.name) && `Betrieb: ${betrieb.name}`) zeile('branche', istText(betrieb.adresse) && `Anschrift: ${betrieb.adresse}`) + zeile('branche', typeof betrieb.kleinunternehmer === 'boolean' && `Kleinunternehmer: ${betrieb.kleinunternehmer ? 'ja' : 'nein'}`) // 2. Wer wird gebucht: Personen und Plaetze getrennt, in der Reihenfolge ihres ersten Auftretens const nachTyp = new Map() diff --git a/public/termine.js b/public/termine.js index 7db98a9..4cb022d 100644 --- a/public/termine.js +++ b/public/termine.js @@ -176,13 +176,16 @@ const neueMeldung = () => h('div', { class: 'tm-meldung', role: 'status', 'aria- /* ----------------------------------------------- Zustand off: Hinweis, Fragebogen --- */ /** - * Ohne laufendes Wartungs-Abo gibt es keinen Fragebogen, nur den Hinweis wie in der Kachel. Der Text kommt ausschliesslich vom Server - * (GET /api/funktionen, termine.hinweis = aboMeldung in server/services/featureOrder.js), nie aus einer Kopie im Browser; bei - * kein_abo kommt der Link zu den Vertraegen dazu (Quelle: vertraege-link.js). Fehlt der Text, weil sich das Abo seit dem Laden der Liste - * geaendert hat, wird die Liste noch einmal geholt. + * Ohne laufendes Wartungs-Abo gibt es keinen Fragebogen, nur den Hinweis. Der Text kommt ausschliesslich vom Server, nie aus einer Kopie im + * Browser: aboHinweis aus GET /api/termine/einstellungen (derselbe Aufruf, der den Zustand liefert, also auch dann gefuellt, wenn das Abo nicht + * lesbar war oder sich seit dem Laden der Liste geaendert hat), ersatzweise termine.hinweis aus GET /api/funktionen (beide: aboMeldung in + * server/services/featureOrder.js). Fehlt der Text auch dort, wird die Liste noch einmal geholt. Bei kein_abo kommt der Link zu den + * Vertraegen dazu (Quelle: vertraege-link.js). „Neu laden“ holt Daten und Ansicht neu, damit der Kunde nach einer Aenderung des Abos nicht + * an der Karte haengen bleibt. */ async function zeigeAboHinweis(s, { aktuell }) { - if (typeof s.termine?.hinweis !== 'string' || s.termine.hinweis === '') { + const textVon = (wert) => (typeof wert === 'string' ? wert : '') + if (textVon(s.daten?.aboHinweis) === '' && textVon(s.termine?.hinweis) === '') { try { await ladeFunktionen(s) } catch { @@ -190,13 +193,16 @@ async function zeigeAboHinweis(s, { aktuell }) { } if (!aktuell()) return } - const hinweis = typeof s.termine?.hinweis === 'string' ? s.termine.hinweis : '' - const link = s.termine?.bestellbar === false && s.termine.grund === 'kein_abo' + const hinweis = textVon(s.daten?.aboHinweis) || textVon(s.termine?.hinweis) + const keinAbo = s.daten?.abo?.grund === 'kein_abo' || (s.termine?.bestellbar === false && s.termine.grund === 'kein_abo') + const link = keinAbo ? h('button', { type: 'button', class: ZU_VERTRAEGE.klasse, 'data-view': ZU_VERTRAEGE.view }, ZU_VERTRAEGE.text) : null s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-info', 'data-hinweis': 'abo' }, h('h3', null, 'Terminplaner einrichten'), - hinweis || link ? h('p', null, hinweis, hinweis && link ? ' ' : null, link) : null)) + hinweis || link ? h('p', null, hinweis, hinweis && link ? ' ' : null, link) : null, + h('div', { class: 'tm-aktionen' }, + h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'neu-laden', onclick: () => zeigeProjekt(s) }, 'Neu laden')))) } /** Der Fragebogen mit der Bestellseite danach (termine-fragebogen.js); die Meldung ueber ihm ist s.meldungEl (ctx.meldung). */ diff --git a/server/routes/termine.js b/server/routes/termine.js index 28ea5dd..217bcfb 100644 --- a/server/routes/termine.js +++ b/server/routes/termine.js @@ -15,8 +15,8 @@ import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuen import { einbauSnippet } from '../services/termine/einbauanleitung.js' import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js' import { loeschTag, loeschzeit } from '../services/termine/frist.js' -import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js' -import { kontextFuerPortal, kontextPerFeature, liveHost, speichereEinstellung, speichereFragebogen } from '../services/termine/kontext.js' +import { BRANCHEN, SCHRITTE, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js' +import { kontextFuerPortal, kontextPerFeature, liveHost, speichereEinstellung, speichereFragebogen, veraltet } from '../services/termine/kontext.js' import { zustellFehler } from '../services/termine/outbox.js' import { termineFuerKunde } from '../services/termine/schalter.js' import { belegungAus, freieZeiten } from '../services/termine/slots.js' @@ -297,7 +297,8 @@ async function aboFuerVorschau(customerId) { /** * Einstellungen und Entwurf für das Portal. Liest in jedem Zustand und auch ohne Funktionsdokument (entwurf: true). * Neben der Einstellung liefert die Antwort den Entwurf des Fragebogens (branchen, fragebogen, kundenansicht, betriebVorschlag - * ohne Einstellung) und in `off` die Sicht der Bestellung: abo (bestellbar() oder null bei Lesefehler), offen (Punkte der + * ohne Einstellung) und in `off` die Sicht der Bestellung: abo (bestellbar() oder null bei Lesefehler), aboHinweis (der Text dazu, wenn das + * Abo nicht bestellbar oder nicht lesbar ist, sonst null), offen (Punkte der * Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). In `ordered` liefert sie bestellung und * offen ebenfalls, nur lesend (Ruling R110: „Zahlung fortsetzen“ ohne Nachweis zeigt dort wieder das Bestellformular); das Abo wird * dort nicht gelesen (abo null), offen hat keinen Abo-Punkt (h). Einbau-Code und Kalender-Links @@ -324,6 +325,9 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou kundenansicht: isKundenansicht(req.session), ...(e ? {} : { betriebVorschlag: await betriebVorschlag(customerId, req.project) }), abo, + // der Text zum Abo (B2): einzige Quelle aboMeldung wie GET /api/funktionen (R111). In off ohne bestellbares Abo, auch wenn es nicht lesbar war + // oder sich zwischen den beiden Abfragen des Browsers geaendert hat; sonst null. Der Browser zeigt ihn an, er hat keine Kopie. + aboHinweis: state === 'off' && abo?.ok !== true ? aboMeldung(register, abo?.grund ?? 'unbekannt') : null, offen: bestellbar ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo, mitAbo: state === 'off' }) : null, bestellung: bestellbar ? await bestellungAnsicht(register) : null, feeds: e && state === 'active' ? feedLinks(ctx) : {}, @@ -462,6 +466,7 @@ const nichtInKundenansicht = route(async (req, _res, next) => { /** * Fragebogen speichern (Branche, bestätigte Schritte, Einbauwünsche; PUT ersetzt den ganzen Fragebogen). Nie in der Kundenansicht: * Schritte bestätigt nur der Kunde selbst (die Erlaubnisliste des Wächters kennt den Pfad nicht, hier gilt es noch einmal). + * Veralteter Tab (die gespeicherte Branche ist eine andere): 409 `EINSTELLUNG_VERALTET`; bestaetigt wird mit dem gespeicherten Stand vereinigt. * Zustandsregeln wie beim Entwurf (off mit laufendem Wartungs-Abo und active ja, ordered, setup und disabled 409). Schreibt nur * fragebogen, updatedAt und updatedBy: die Version der Einstellung bleibt, ein offener Einstellungs-Editor kann weiter speichern. * Ohne Einstellung 409 `keine_einstellung`. Antwort { fragebogen, offen }; `offen` wie im GET nur in `off`. Kein Protokolleintrag. @@ -473,7 +478,19 @@ router.put('/fragebogen', requireSession, nichtInKundenansicht, withTermine({ sc let gespeichert try { - gespeichert = await speichereFragebogen(feature, () => geprueft.fragebogen, { by: von(req), now: uhr() }) + // Unter der Sperre gegen den gespeicherten Stand gelesen (ein zweiter Tab kann dazwischen gespeichert haben): die Branche muss die + // gespeicherte sein, sonst ist dieser Tab veraltet (409 EINSTELLUNG_VERALTET; die Branche wechselt nur POST /einstellungen/vorlage, + // und mit ihr Vorlage, Preise und Bestätigungen). Bestätigungen gehen nie verloren: bestaetigt ist die Vereinigung aus dem + // gespeicherten und dem gesendeten Stand (zurückgesetzt werden sie nur durch eine neue Vorlage). + gespeichert = await speichereFragebogen( + feature, + (bisher) => { + if (bisher.branche && bisher.branche !== geprueft.fragebogen.branche) throw veraltet() + const bestaetigt = SCHRITTE.filter((schritt) => bisher.bestaetigt?.includes(schritt) || geprueft.fragebogen.bestaetigt.includes(schritt)) + return { ...geprueft.fragebogen, bestaetigt } + }, + { by: von(req), now: uhr() }, + ) } catch (err) { throw err?.code === 'keine_einstellung' ? stop(409, MELDUNG.keineEinstellung, 'keine_einstellung') : err } diff --git a/server/services/termine/kontext.js b/server/services/termine/kontext.js index 7b1c37c..a46d0ec 100644 --- a/server/services/termine/kontext.js +++ b/server/services/termine/kontext.js @@ -214,7 +214,7 @@ export async function kontextFuerPortal(feature) { return baueKontext(feature) } -const veraltet = () => +export const veraltet = () => Object.assign(new Error('Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'), { status: 409, code: 'EINSTELLUNG_VERALTET', diff --git a/test/support/fakeDom.js b/test/support/fakeDom.js index 44d5b59..90b5eff 100644 --- a/test/support/fakeDom.js +++ b/test/support/fakeDom.js @@ -3,7 +3,8 @@ // const fx = installFakeDom({ antworten: [{ status: 400, body: { fehler: {...} } }] }) // try { ...Client-Code bauen, fx.klick(knopf), fx.fetchLog pruefen... } finally { fx.restore() } // -// installFakeDom stellt `document`, `window` (nur location mit href, pathname, search, hash und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den +// installFakeDom stellt `document`, `window` (location mit href, pathname, search, hash und reload() - jeder Aufruf steht in fx.reloads -, history.replaceState und +// addEventListener/removeEventListener/dispatchEvent fuer Ereignisse des Fensters wie 'pageshow'), `CSS.escape` und `fetch` auf globalThis, restore() stellt den // alten Zustand wieder her. `h` aus public/termine-dom.js liest `document` bei jedem Aufruf: der Client braucht keine Einhaengepunkte. // Wurzeln, die `isConnected` brauchen (neuerSpeicher.nochHier), haengt der Test selbst an `document.body`. // @@ -471,7 +472,7 @@ const GLOBALE = ['document', 'window', 'CSS', 'fetch'] /** * Fake-DOM und Fake-fetch auf globalThis stellen. - * => { document, window, fetchLog, antworten, finde, alle, text, klick, setze, sende, ruhe, restore } + * => { document, window, fetchLog, reloads, antworten, finde, alle, text, klick, setze, sende, ruhe, restore } * finde(baum, vergleich) / alle(baum, vergleich): erstes / alle Elemente im Baum (baum selbst eingeschlossen, Dokumentreihenfolge), fuer die * vergleich(el) wahr ist (Funktion) oder die zum Selektor passen (String). * text(baum): Text des Baums (textContent). klick(el), setze(el, wert), ruhe(): siehe Kopf der Datei. @@ -479,11 +480,28 @@ const GLOBALE = ['document', 'window', 'CSS', 'fetch'] export function installFakeDom({ antworten = [] } = {}) { const vorher = Object.fromEntries(GLOBALE.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)])) const document = neuesDokument() - const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '' } + const reloads = [] + const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '', reload: () => { reloads.push(location.href) } } + const fensterEreignisse = new Map() const window = { document, location, history: { replaceState: (_zustand, _titel, url) => { location.hash = String(url ?? '').includes('#') ? `#${String(url).split('#')[1]}` : '' } }, + addEventListener(typ, fn) { + if (!fensterEreignisse.has(typ)) fensterEreignisse.set(typ, []) + fensterEreignisse.get(typ).push(fn) + }, + removeEventListener(typ, fn) { + const liste = fensterEreignisse.get(typ) + if (liste?.includes(fn)) liste.splice(liste.indexOf(fn), 1) + }, + /** Ereignis an die Handler des Fensters (z. B. { type: 'pageshow', persisted: true }); Rueckgaben werden nicht abgewartet. */ + dispatchEvent(ev) { + for (const fn of [...(fensterEreignisse.get(ev.type) ?? [])]) fn(ev) + return true + }, + /** Zahl der Handler eines Typs (fuer Tests, die Aufraeumen pruefen). */ + anzahlHandler: (typ) => (fensterEreignisse.get(typ) ?? []).length, } const fetchLog = [] const warteschlange = [...antworten] @@ -503,6 +521,7 @@ export function installFakeDom({ antworten = [] } = {}) { document, window, fetchLog, + reloads, antworten: warteschlange, finde: (baum, vergleich) => knoten(baum).find(pruefung(vergleich)) ?? null, alle: (baum, vergleich) => knoten(baum).filter(pruefung(vergleich)), diff --git a/test/termineFragebogenRoutes.test.js b/test/termineFragebogenRoutes.test.js index 7886e6a..588d593 100644 --- a/test/termineFragebogenRoutes.test.js +++ b/test/termineFragebogenRoutes.test.js @@ -329,6 +329,7 @@ test('abo gibt es nur in off; offen und bestellung nur in off und ordered (R110) setzeZustand(zustand) const { body } = await send('GET', einstellungenUrl(), { cookie }) assert.equal(body.abo, null, zustand) + assert.equal(body.aboHinweis, null, `${zustand}: den Abo-Hinweis gibt es nur in off`) assert.equal(body.offen, null, zustand) assert.equal(body.bestellung, null, zustand) } @@ -340,6 +341,7 @@ test('abo gibt es nur in off; offen und bestellung nur in off und ordered (R110) assert.notEqual(body.offen, null, zustand) assert.notEqual(body.bestellung, null, zustand) assert.equal(body.abo === null, zustand === 'ordered', `${zustand}: das Abo liest die Route nur in off`) + assert.equal(body.aboHinweis, null, `${zustand}: mit laufendem Abo (off) und in ordered kein Hinweis`) } }) @@ -406,6 +408,7 @@ test('off mit bestellbarem Entwurf und laufendem Wartungs-Abo: abo { ok: true }, setzeZustand('off') const { body } = await send('GET', einstellungenUrl(), { cookie }) assert.deepEqual(body.abo, { ok: true }) + assert.equal(body.aboHinweis, null, 'laeuft das Abo, gibt es keinen Hinweis') assert.deepEqual(body.offen, []) assert.deepEqual(body.bestellung, JSON.parse(JSON.stringify(await bestellungAnsicht(T)))) assert.equal(body.bestellung.consent.knopf, T.consent.knopf, 'der Knopf zeigt genau den Registertext') @@ -454,6 +457,7 @@ test('Abo ohne laufendes Wartungs-Abo (nur Hosting, oder gar keins): abo { ok: f const { body } = await send('GET', einstellungenUrl(), { cookie }) assert.equal(body.state, 'off', name) assert.deepEqual(body.abo, { ok: false, grund: 'kein_abo' }, name) + assert.equal(body.aboHinweis, text, `${name}: B2, der Hinweis ist derselbe Text aus aboMeldung`) assert.deepEqual(body.offen, [{ schritt: 'bestellen', text }], name) } }) @@ -463,12 +467,14 @@ test('Abo mit vorgemerkter Kündigung: grund gekuendigt; past_due: grund nicht_a wartung({ cancelAtPeriodEnd: true }) let body = (await send('GET', einstellungenUrl(), { cookie })).body assert.deepEqual(body.abo, { ok: false, grund: 'gekuendigt' }) + assert.equal(body.aboHinweis, aboMeldung(T, 'gekuendigt')) assert.deepEqual(body.offen, [{ schritt: 'bestellen', text: aboMeldung(T, 'gekuendigt') }]) assert.equal(body.offen[0].text, 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.') wartung({ status: 'past_due' }) body = (await send('GET', einstellungenUrl(), { cookie })).body assert.deepEqual(body.abo, { ok: false, grund: 'nicht_aktiv' }) + assert.equal(body.aboHinweis, aboMeldung(T, 'nicht_aktiv')) assert.deepEqual(body.offen, [{ schritt: 'bestellen', text: aboMeldung(T, 'nicht_aktiv') }]) }) @@ -478,6 +484,8 @@ test('Lesefehler beim Abo: GET bleibt 200, abo null und der offene Punkt "nicht const res = await send('GET', einstellungenUrl(), { cookie }) assert.equal(res.status, 200) assert.equal(res.body.abo, null) + assert.equal(res.body.aboHinweis, aboMeldung(T, 'unbekannt'), 'B2: auch ohne lesbares Abo bekommt der Kunde einen Text, nie eine leere Karte') + assert.equal(res.body.aboHinweis, 'Das Abo konnte gerade nicht geprüft werden.') assert.deepEqual(res.body.offen, [{ schritt: 'bestellen', text: aboMeldung(T, 'unbekannt') }]) assert.equal(res.body.offen[0].text, 'Das Abo konnte gerade nicht geprüft werden.') assert.ok(!JSON.stringify(res.body).includes('geheimer')) @@ -1374,8 +1382,17 @@ const beispielFragebogen = (over = {}) => ({ ...over, }) +/** + * Bestellbarer Stand, aber noch kein Schritt bestätigt und noch keine Branche gewählt (B1): PUT vereinigt bestaetigt mit dem gespeicherten Stand und + * lässt einen Wechsel der gespeicherten Branche nicht zu. Die Tests, die einen beliebigen Fragebogen per PUT ablegen, gehen von diesem Stand aus. + */ +function standOhneBestaetigung({ branche = 'salon' } = {}) { + const stand = bestellbarerStand({ now: JETZT }) + return { ...stand, fragebogen: { ...stand.fragebogen, branche, bestaetigt: [] } } +} + test('PUT fragebogen speichert nur den Fragebogen: version und einstellung der Einstellung bleiben, updatedAt/updatedBy ändern sich, Antwort { fragebogen, offen }', async () => { - setzeZustand('off') + setzeZustand('off', { stand: standOhneBestaetigung() }) const vorher = termineConfigDoc() const res = await putFragebogen(beispielFragebogen()) assert.equal(res.status, 200, JSON.stringify(res.body)) @@ -1394,7 +1411,7 @@ test('PUT fragebogen speichert nur den Fragebogen: version und einstellung der E }) test('PUT fragebogen: bereinigt die Eingabe wie normalisiereFragebogen (Reihenfolge, Doppelte, Fremdes, Steuerzeichen) und ersetzt den ganzen Fragebogen', async () => { - setzeZustand('off') + setzeZustand('off', { stand: standOhneBestaetigung() }) const res = await putFragebogen({ branche: 'salon', bestaetigt: ['website', 'branche', 'branche', 'unbekannt'], @@ -1459,7 +1476,7 @@ test('PUT fragebogen ohne Einstellung => 409 keine_einstellung, nichts angelegt for (const zustand of ZUSTAENDE) { const erwartet = { keins: '409 keine_einstellung', off: '200', ordered: '409 gesperrt', setup: '409 gesperrt', active: '200', disabled: '409 abgeschaltet' }[zustand] test(`PUT fragebogen: Zustand ${zustand} => ${erwartet}`, async () => { - setzeZustand(zustand) + setzeZustand(zustand, { stand: standOhneBestaetigung() }) const vorher = dokumente() const res = await putFragebogen(beispielFragebogen()) if (erwartet === '200') { @@ -1504,7 +1521,8 @@ test('PUT fragebogen: in off nur mit laufendem Wartungs-Abo (409 kein_abo, 503 b }) test('PUT fragebogen: offen ist die Bestellprüfung des gespeicherten Stands (nur in off), null in active', async () => { - const stand = setzeZustand('off') + const stand = bestellbarerStand({ now: JETZT }) + setzeZustand('off', { stand: standOhneBestaetigung() }) const unvollstaendig = await putFragebogen(beispielFragebogen()) assert.equal(unvollstaendig.status, 200) const erwartet = bestellpruefung({ einstellung: stand.einstellung, fragebogen: unvollstaendig.body.fragebogen, feature: T, abo: { ok: true } }) @@ -1523,13 +1541,66 @@ test('PUT fragebogen: offen ist die Bestellprüfung des gespeicherten Stands (nu assert.equal((await putFragebogen(beispielFragebogen())).body.offen, null) }) -test('PUT fragebogen: Branche gesundheit lässt sich speichern, das Gate (offen) blockt sie aber', async () => { - setzeZustand('off') +test('PUT fragebogen: Branche gesundheit lässt sich speichern, solange noch keine Branche gespeichert ist; das Gate (offen) blockt sie aber', async () => { + setzeZustand('off', { stand: standOhneBestaetigung({ branche: null }) }) const res = await putFragebogen({ ...bestellbarerStand({ now: JETZT }).fragebogen, branche: 'gesundheit' }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.ok(res.body.offen.some((p) => p.schritt === 'branche')) }) +test('B1: ein veralteter Tab (Branche inzwischen gewechselt) bekommt 409 EINSTELLUNG_VERALTET; Branche, Bestätigungen und Preise des neuen Stands bleiben', async () => { + setzeZustand('off') + const tabA = (await send('GET', einstellungenUrl(), { cookie })).body // Tab A zeigt Branche salon, alle Schritte bestätigt, Preise geprüft + assert.equal(tabA.fragebogen.branche, 'salon') + assert.equal(tabA.fragebogen.preiseGeprueft, true) + + // Tab B wechselt die Branche: Vorlage ersetzt, Fragebogen beginnt neu (Bestätigungen und Preisprüfung weg) + const tabB = await vorlage({ branche: 'kosmetik', ersetzen: true, version: tabA.version }) + assert.equal(tabB.status, 201, JSON.stringify(tabB.body)) + const nachB = termineConfigDoc() + + // Tab A drückt „Weiter“: PUT mit dem alten Stand + const res = await putFragebogen(tabA.fragebogen) + assert.equal(res.status, 409, JSON.stringify(res.body)) + assert.equal(res.body.code, 'EINSTELLUNG_VERALTET') + assert.match(res.body.error, /Bitte neu laden/) + assert.equal(termineConfigDoc().fragebogen, nachB.fragebogen, 'der gespeicherte Fragebogen bleibt byte-gleich') + const gespeichert = gespeicherterFragebogen() + assert.deepEqual([gespeichert.branche, gespeichert.bestaetigt, gespeichert.preiseGeprueft], ['kosmetik', [], false]) + const gate = (await send('GET', einstellungenUrl(), { cookie })).body + assert.ok(gate.offen.some((p) => p.schritt === 'leistungen'), 'die ungeprüften Vorlagenpreise gelten nicht als geprüft') +}) + +test('B1: bestaetigt ist die Vereinigung aus gespeichertem und gesendetem Stand; die übrigen Felder ersetzt der PUT wie bisher', async () => { + const stand = standOhneBestaetigung() + stand.fragebogen.bestaetigt = ['branche', 'wer', 'leistungen'] // Tab B hat schon weiter bestätigt + setzeZustand('off', { stand }) + + // Tab A kennt nur branche und wer und bestätigt zeiten; seine Preisangabe und sein Einbau gelten (PUT ersetzt den Rest) + const res = await putFragebogen(beispielFragebogen({ bestaetigt: ['branche', 'wer', 'zeiten'], preiseGeprueft: false })) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body.fragebogen.bestaetigt, ['branche', 'wer', 'leistungen', 'zeiten'], 'nichts geht verloren, in der Reihenfolge der Schritte') + assert.equal(res.body.fragebogen.preiseGeprueft, false) + assert.deepEqual(gespeicherterFragebogen(), res.body.fragebogen) + + // ohne gespeicherte Bestätigungen oder mit einer Eingabe ohne bestaetigt: nur die Vereinigung zählt + const leer = await putFragebogen({ ...beispielFragebogen(), bestaetigt: undefined }) + assert.equal(leer.status, 200) + assert.deepEqual(leer.body.fragebogen.bestaetigt, ['branche', 'wer', 'leistungen', 'zeiten']) +}) + +test('B1: dieselbe Branche geht durch; eine andere als die gespeicherte (auch null) ist 409, solange eine gespeichert ist', async () => { + setzeZustand('off', { stand: standOhneBestaetigung() }) + assert.equal((await putFragebogen(beispielFragebogen({ branche: 'salon' }))).status, 200) + const vorher = dokumente() + for (const branche of ['kosmetik', 'gesundheit', null]) { + const res = await putFragebogen(beispielFragebogen({ branche })) + assert.equal(res.status, 409, String(branche)) + assert.equal(res.body.code, 'EINSTELLUNG_VERALTET') + } + assert.equal(dokumente(), vorher, 'nichts geändert') +}) + // ---------------------------------------------------------------- PUT: Kundenansicht for (const zustand of ZUSTAENDE) { @@ -1553,7 +1624,7 @@ for (const zustand of ZUSTAENDE) { } test('PUT fragebogen am Router: dieselbe Anfrage mit der Sitzung des Kunden selbst geht durch (der Router sperrt nur die Kundenansicht)', async () => { - setzeZustand('off') + setzeZustand('off', { stand: standOhneBestaetigung() }) const res = await sendDirekt('PUT', FRAGEBOGEN_URL, { cookie, body: { projectId: 'p1', fragebogen: beispielFragebogen() } }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.deepEqual(gespeicherterFragebogen(), beispielFragebogen()) diff --git a/test/termineFragebogenView.test.js b/test/termineFragebogenView.test.js index 1e1e274..41912c3 100644 --- a/test/termineFragebogenView.test.js +++ b/test/termineFragebogenView.test.js @@ -337,6 +337,23 @@ test('zusammenfassung (Vorlage salon): Name, Branche und Anschrift', () => { assert.ok(zeilen.includes('Anschrift: Hauptstraße 1, 67655 Kaiserslautern')) }) +test('B5: zusammenfassung nennt in „Branche und Betrieb“ ausdrücklich „Kleinunternehmer: ja“ oder „nein“; ohne Angabe keine Zeile', () => { + const zeilen = (einstellung, fragebogen) => gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'branche').zeilen + const nein = stand() + assert.equal(nein.einstellung.betrieb.kleinunternehmer, false) + assert.deepEqual(zeilen(nein.einstellung, nein.fragebogen).filter((z) => z.startsWith('Kleinunternehmer')), ['Kleinunternehmer: nein']) + const ja = stand((e) => { e.betrieb.kleinunternehmer = true }) + assert.deepEqual(zeilen(ja.einstellung, ja.fragebogen).filter((z) => z.startsWith('Kleinunternehmer')), ['Kleinunternehmer: ja']) + assert.deepEqual(zeilen(ja.einstellung, ja.fragebogen).slice(0, 4), ['Branche: Friseur und Salon', 'Betrieb: Salon Muster', 'Anschrift: Hauptstraße 1, 67655 Kaiserslautern', 'Kleinunternehmer: ja'], 'nach der Anschrift') + // fehlt die Angabe oder ist sie kein Wahrheitswert, wird nichts erfunden + for (const wert of [undefined, null, 'ja', 1]) { + const ohne = stand((e) => { e.betrieb.kleinunternehmer = wert }) + assert.ok(!zeilen(ohne.einstellung, ohne.fragebogen).some((z) => z.startsWith('Kleinunternehmer')), String(wert)) + } + const keine = zusammenfassung(null, null, BRANCHEN) + assert.ok(!JSON.stringify(keine).includes('Kleinunternehmer')) +}) + test('zusammenfassung (Vorlage salon): Leistungen mit Preis', () => { const { einstellung, fragebogen } = stand() const zeilen = gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'leistungen').zeilen @@ -1189,16 +1206,64 @@ test('renderBestellen: Zusammenfassung zeigt die acht Gruppen aus zusammenfassun assert.deepEqual(schritte, ['leistungen', 'website']) })) -test('renderBestellen: eine Gruppe ohne Angaben sagt es und hat trotzdem „Ändern“; fehlendes zumSchritt ist kein Fehler', mitDom(undefined, async (fx) => { +test('renderBestellen: eine Gruppe ohne Angaben sagt es und hat trotzdem „Ändern“ (mit zumSchritt)', mitDom(undefined, async (fx) => { const daten = await bestellDaten({ einstellung: null, fragebogen: null }) - const wurzel = h('div') - document.body.append(wurzel) - const ctx = huelle.baueCtx({ daten, nurLesen: false }, { projekt: { projectId: 'p1' }, aktuell: () => true }) - bestellen.renderBestellen(wurzel, { ctx, daten }) + const { wurzel, schritte } = await bestellSeite(daten) const gruppen = fx.alle(wurzel, '.tm-bestellen-gruppe') assert.equal(gruppen.length, 8) assert.match(fx.text(gruppen[0]), /Noch keine Angaben/) await fx.klick(fx.finde(gruppen[0], '[data-aktion="aendern"]')) + assert.deepEqual(schritte, ['branche']) +})) + +test('B4: ohne zumSchritt (keine Funktion) zeichnet die Bestellseite weder „Ändern“ noch „Zum Schritt“ - keine Knöpfe, die nichts tun', mitDom(undefined, async (fx) => { + const offen = [{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' }, { schritt: 'bestellen', text: 'Das Abo konnte gerade nicht geprüft werden.' }] + for (const zumSchritt of [undefined, null, 'website', 42, {}]) { + const daten = await bestellDaten({ offen }) + const wurzel = h('div') + document.body.append(wurzel) + const ctx = huelle.baueCtx({ daten, nurLesen: false }, { projekt: { projectId: 'p1' }, aktuell: () => true }) + bestellen.renderBestellen(wurzel, { ctx, daten, zumSchritt }) + const name = JSON.stringify(zumSchritt) + assert.equal(fx.alle(wurzel, '.tm-bestellen-gruppe').length, 8, `${name}: die Zusammenfassung steht`) + assert.equal(fx.alle(wurzel, '[data-aktion="aendern"]').length, 0, `${name}: kein „Ändern“`) + assert.equal(fx.alle(wurzel, '[data-aktion="zum-schritt"]').length, 0, `${name}: kein „Zum Schritt“`) + assert.deepEqual(fx.alle(wurzel, '.tm-bestellen-offen li span').map((span) => span.textContent), offen.map((p) => p.text), `${name}: die offenen Punkte bleiben als Text`) + } + // mit Funktion ist alles wie bisher da + const { wurzel } = await bestellSeite(await bestellDaten({ offen })) + assert.equal(fx.alle(wurzel, '[data-aktion="aendern"]').length, 8) + assert.deepEqual(fx.alle(wurzel, '[data-aktion="zum-schritt"]').map((k) => k.dataset.schritt), ['website']) +})) + +test('B4: nach einem 409 bekommt die Meldung den Fokus (tabindex -1), auf der neuen Seite, bei fehlgeschlagenem Neuladen und auf der Hinweiskarte', mitDom(undefined, async (fx) => { + const fokusAufMeldung = (wurzel, was) => { + const meldung = fx.finde(wurzel, '[data-meldung="bestellen"]') + assert.ok(meldung, was) + assert.equal(meldung.getAttribute('tabindex'), '-1', `${was}: fokussierbar, aber nicht im Tabulatorweg`) + assert.equal(fx.document.activeElement, meldung, `${was}: Fokus auf der Meldung`) + } + // a) Seite wird neu gezeichnet + fx.antworten.push({ status: 409, body: { error: 'Die Bestellung ist noch nicht vollständig.', code: 'bestellung_offen', offen: [{ schritt: 'website', text: 'x' }] } }, { body: await bestellDaten() }) + const a = await bestellSeite(await bestellDaten()) + await alleAnhaken(fx, a.wurzel) + await fx.klick(bestellKnopf(fx, a.wurzel)) + fokusAufMeldung(a.wurzel, 'neu gezeichnet') + // b) Neuladen scheitert: dieselbe Seite, Meldung mit Fokus + fx.antworten.push({ status: 409, body: { error: 'Dein Wartungs-Abo ist gekündigt.', code: 'gekuendigt' } }, { status: 500, body: { error: 'Server kaputt.' } }) + const b = await bestellSeite(await bestellDaten()) + await alleAnhaken(fx, b.wurzel) + await fx.klick(bestellKnopf(fx, b.wurzel)) + fokusAufMeldung(b.wurzel, 'Neuladen gescheitert') + // c) die Bestellung ist inzwischen aufgegeben: die Karte mit der Meldung bekommt den Fokus + fx.antworten.push({ status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, { body: await bestellDaten({ state: 'ordered', bestellung: null, offen: null }) }) + const c = await bestellSeite(await bestellDaten()) + await alleAnhaken(fx, c.wurzel) + await fx.klick(bestellKnopf(fx, c.wurzel)) + const karteEl = fx.finde(c.wurzel, '.tm-info') + assert.ok(karteEl) + assert.equal(karteEl.getAttribute('tabindex'), '-1') + assert.equal(fx.document.activeElement, karteEl, 'Fokus auf der Hinweiskarte') })) test('renderBestellen: offene Punkte mit „Zum Schritt“ zu ihrem Schritt, der Abo-Punkt (bestellen) ohne Knopf; Knopf gesperrt', mitDom(undefined, async (fx) => { @@ -1443,6 +1508,41 @@ test('statusKarte ordered: „Zahlung fortsetzen“ sendet fortsetzenKoerper und assert.equal(fx.window.location.href, KNOPF_URL) })) +test('B3: zurück aus dem Bfcache (pageshow persisted) lädt die Karte „Zahlung fortsetzen“ neu - nicht gesperrt und ohne Meldung stehen lassen', mitDom({ antworten: [{ body: { url: KNOPF_URL } }] }, async (fx) => { + const wurzel = karte() + const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]') + await fx.klick(knopf) + assert.equal(fx.window.location.href, KNOPF_URL) + assert.equal(knopf.disabled, true, 'bis zum Verlassen der Seite gesperrt') + // ein normales pageshow (erste Anzeige) ändert nichts + fx.window.dispatchEvent({ type: 'pageshow', persisted: false }) + assert.deepEqual(fx.reloads, []) + // Zurück aus der Zahlungsseite: der Browser stellt die Seite samt gesperrtem Knopf wieder her + fx.window.dispatchEvent({ type: 'pageshow', persisted: true }) + assert.equal(fx.reloads.length, 1, 'die Seite wird neu geladen') +})) + +test('B3: eine Karte, die nicht mehr im Baum steht, lädt nichts neu und ihr Handler verschwindet', mitDom(undefined, (fx) => { + const wurzel = karte() + assert.equal(fx.window.anzahlHandler('pageshow'), 1) + wurzel.remove() + fx.window.dispatchEvent({ type: 'pageshow', persisted: true }) + assert.deepEqual(fx.reloads, []) + assert.equal(fx.window.anzahlHandler('pageshow'), 0) +})) + +test('B3: auch die Bestellseite lädt bei der Rückkehr aus dem Bfcache neu, solange sie im Baum steht', mitDom({ antworten: [{ body: { url: KNOPF_URL } }] }, async (fx) => { + const { wurzel } = await bestellSeite(await bestellDaten()) + await alleAnhaken(fx, wurzel) + await fx.klick(bestellKnopf(fx, wurzel)) + assert.equal(fx.window.location.href, KNOPF_URL) + assert.equal(bestellKnopf(fx, wurzel).disabled, true) + fx.window.dispatchEvent({ type: 'pageshow', persisted: false }) + assert.deepEqual(fx.reloads, []) + fx.window.dispatchEvent({ type: 'pageshow', persisted: true }) + assert.equal(fx.reloads.length, 1) +})) + test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter({ bestellen: true, meldung }) lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({ antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }], }, async (fx) => { @@ -3099,8 +3199,9 @@ test('Ansicht off ohne laufendes Abo: der Hinweis vom Server (kein_abo mit Link const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]') assert.ok(karteEl) assert.ok(fx.text(karteEl).includes(SERVER_HINWEIS), 'der Text des Servers, unverändert') - const link = fx.alle(karteEl, 'button') + const link = fx.alle(karteEl, 'button[data-view]') assert.deepEqual(link.map((b) => [fx.text(b), b.getAttribute('data-view'), b.getAttribute('class'), b.getAttribute('type')]), [['Zu Verträge', 'vertraege', 'kb-link', 'button']]) + assert.deepEqual(fx.alle(karteEl, 'button').map((b) => b.getAttribute('data-aktion') ?? b.getAttribute('data-view')), ['vertraege', 'neu-laden'], 'dazu „Neu laden“ (B2)') assert.equal(fx.text(fx.finde(karteEl, 'p')), `${SERVER_HINWEIS} Zu Verträge`, 'Text, Leerzeichen, Link wie in der Kachel') assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen') assert.equal(fx.alle(wurzel, 'input').length, 0) @@ -3120,11 +3221,41 @@ test('Ansicht off ohne Abo: gekündigt, nicht aktiv, nicht lesbar und fehlendes const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]') assert.ok(karteEl, name) assert.equal(fx.text(fx.finde(karteEl, 'p')), termine.hinweis, name) - assert.equal(fx.alle(karteEl, 'button').length, 0, `${name}: kein Link`) + assert.equal(fx.alle(karteEl, 'button[data-view]').length, 0, `${name}: kein Link`) assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, name) } })) +test('B2: der Text zum Abo kommt mit den Daten (aboHinweis), auch wenn die Liste „bestellbar“ sagt und das Abo nicht lesbar war; Link bei kein_abo; „Neu laden“ holt alles neu', mitDom(undefined, async (fx) => { + // das Abo war beim Lesen der Daten nicht lesbar (abo null), die Liste kennt keinen Hinweis: früher stand nur die Überschrift da + const HINWEIS = 'Das Abo konnte gerade nicht geprüft werden.' + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: null, aboHinweis: HINWEIS } }) }) + const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]') + assert.ok(karteEl) + assert.equal(fx.text(fx.finde(karteEl, 'p')), HINWEIS, 'der Text des Servers aus den Daten') + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')], 'dafür braucht es keine weitere Abfrage der Liste') + assert.equal(fx.alle(karteEl, 'button[data-view]').length, 0, 'ohne Grund kein_abo kein Link') + + // kein_abo steht in den Daten: der Link zu Verträge auch ohne Angaben der Liste + const kein = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'kein_abo' }, aboHinweis: SERVER_HINWEIS } }) }) + const kleiner = fx.finde(kein, '[data-hinweis="abo"]') + assert.equal(fx.text(fx.finde(kleiner, 'p')), `${SERVER_HINWEIS} Zu Verträge`) + + // „Neu laden“: Daten noch einmal holen und die Ansicht nach dem neuen Stand zeichnen (das Abo läuft inzwischen: der Fragebogen) + const neu = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { + daten: { abo: null, aboHinweis: HINWEIS }, + tabelle: { [an('p1')]: [{ body: await ansichtDaten('off', { abo: null, aboHinweis: HINWEIS }) }, { body: await ansichtDaten('off', { fragebogen: fbBis(1) }) }] }, + }) }) + const laden = fx.finde(neu, '[data-hinweis="abo"] button[data-aktion="neu-laden"]') + assert.ok(laden, 'Knopf „Neu laden“') + assert.equal(fx.text(laden), 'Neu laden') + assert.equal(laden.getAttribute('type'), 'button') + await fx.klick(laden) + await fx.ruhe() + assert.ok(fx.finde(neu, '[data-hinweis="abo"]') === null, 'die Karte ist weg') + assert.ok(fx.finde(neu, 'nav.tm-fortschritt'), 'der Fragebogen steht da') +})) + test('Ansicht off ohne Abo: der Hinweis kommt maskiert in die Seite und der Browser kennt keinen eigenen Text', mitDom(undefined, async (fx) => { const bose = ' & "Anführungszeichen"' const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt', bose) }) })