From 6f0808647f20cdf114f8249678eb0f1fa06a62e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:50:12 +0000 Subject: [PATCH] Kundenansicht neu P1: h(), api() mit ApiFehler und Quelltext-Regel Co-Authored-By: Claude Sonnet 5.5 --- public/neu/api.js | 54 ++++++++++++++++++++++++++++++++++ public/neu/dom.js | 28 ++++++++++++++++++ test/knHuelleDomApi.test.js | 42 ++++++++++++++++++++++++++ test/knHuelleQuelltext.test.js | 16 ++++++++++ 4 files changed, 140 insertions(+) create mode 100644 public/neu/api.js create mode 100644 public/neu/dom.js create mode 100644 test/knHuelleDomApi.test.js create mode 100644 test/knHuelleQuelltext.test.js diff --git a/public/neu/api.js b/public/neu/api.js new file mode 100644 index 0000000..e2ea6cd --- /dev/null +++ b/public/neu/api.js @@ -0,0 +1,54 @@ +/* Kundenansicht neu: ein einziger Zugang zur Schnittstelle des Portals. + Fehler kommen immer als ApiFehler (mit Status, Code, Text, Feld und dem ganzen Body), Netzfehler mit Status 0. */ + +const TEXT_NETZ = 'Keine Verbindung. Bitte versuch es gleich noch einmal.' +const TEXT_502 = 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' + +// Sitzung beendet (Passwort auf einem anderen Gerät geändert): nur einmal zur Anmeldung wechseln +let sitzungBeendetGemeldet = false + +export class ApiFehler extends Error { + constructor(message, { status = 0, code = '', field = '', body = {} } = {}) { + super(message) + this.name = 'ApiFehler' + this.status = status + this.code = code + this.field = field + this.body = body + } +} + +/** Aufruf der Schnittstelle. `roh` ist ein Rohdaten-Body (Blob, ArrayBuffer …), der unverändert als application/octet-stream geht. */ +export async function api(pfad, { method = 'GET', body, roh } = {}) { + const headers = {} + let inhalt + if (roh) { + headers['Content-Type'] = 'application/octet-stream' + inhalt = roh + } else if (body !== undefined && body !== null) { + headers['Content-Type'] = 'application/json' + inhalt = JSON.stringify(body) + } + let antwort + try { + antwort = await fetch(pfad, { method, credentials: 'same-origin', headers, body: inhalt }) + } catch { + // Netzfehler (offline, Server nicht erreichbar): deutscher Text statt „Failed to fetch“; Status 0 = nie eine Antwort bekommen + throw new ApiFehler(TEXT_NETZ, { status: 0 }) + } + const daten = await antwort.json().catch(() => ({})) + if (antwort.ok) return daten + const antwortBody = daten !== null && typeof daten === 'object' && !Array.isArray(daten) ? daten : {} + const message = typeof antwortBody.error === 'string' && antwortBody.error ? antwortBody.error : (antwort.status === 502 ? TEXT_502 : `Fehler ${antwort.status}`) + const fehler = new ApiFehler(message, { + status: antwort.status, + code: typeof antwortBody.code === 'string' ? antwortBody.code : '', + field: typeof antwortBody.field === 'string' ? antwortBody.field : '', + body: antwortBody, + }) + if (fehler.code === 'session_revoked' && !sitzungBeendetGemeldet) { + sitzungBeendetGemeldet = true + window.location.href = `/login.html?error=${encodeURIComponent(message)}` + } + throw fehler +} diff --git a/public/neu/dom.js b/public/neu/dom.js new file mode 100644 index 0000000..93a22cc --- /dev/null +++ b/public/neu/dom.js @@ -0,0 +1,28 @@ +/* Kundenansicht neu: Elemente bauen, ohne dass Kundentext je als HTML gelesen wird. + Texte werden Textknoten, nie innerHTML. Das Modul liest `document` erst beim Aufruf und ist in Node importierbar. */ + +/** Element bauen. attrs.class setzt die Klasse, attrs.on = { click: fn } hängt Ereignisse an, true setzt ein Attribut ohne Wert, + * false/null/undefined lassen es weg. Kinder werden flach gelegt, Strings und Zahlen werden Textknoten. */ +export function h(tag, attrs = {}, ...kinder) { + const el = document.createElement(tag) + for (const [name, wert] of Object.entries(attrs || {})) { + if (wert === undefined || wert === null || wert === false) continue + if (name === 'class') el.className = wert + else if (name === 'on') for (const [typ, fn] of Object.entries(wert)) el.addEventListener(typ, fn) + else el.setAttribute(name, wert === true ? '' : String(wert)) + } + for (const kind of kinder.flat(Infinity)) if (kind !== undefined && kind !== null && kind !== false) el.append(typeof kind === 'number' ? String(kind) : kind) + return el +} + +/** Alle Kinder entfernen. */ +export function leeren(el) { + el.replaceChildren() + return el +} + +/** Text setzen (nie HTML); null und undefined werden zum leeren Text. */ +export function text(el, wert) { + el.textContent = String(wert ?? '') + return el +} diff --git a/test/knHuelleDomApi.test.js b/test/knHuelleDomApi.test.js new file mode 100644 index 0000000..ef852b2 --- /dev/null +++ b/test/knHuelleDomApi.test.js @@ -0,0 +1,42 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { installFakeDom } from './support/fakeDom.js' +import { h, leeren, text } from '../public/neu/dom.js' +import { api, ApiFehler } from '../public/neu/api.js' + +test('h: Kundentext bleibt Text; on, boolesche und fehlende Attribute; leeren, text', async () => { + const fx = installFakeDom() + try { + let klicks = 0 + const k = h('button', { class: 'kn-knopf', hidden: false, 'data-x': true, on: { click: () => { klicks += 1 } } }, 'x', [null, 'y']) + await fx.klick(k) + assert.deepEqual([klicks, k.className, k.hasAttribute('hidden'), k.getAttribute('data-x')], [1, 'kn-knopf', false, '']) + assert.deepEqual([fx.text(k), k.children.length], ['xy', 0]) + assert.equal(leeren(k).childNodes.length, 0); assert.equal(text(k, null).textContent, '') + } finally { fx.restore() } +}) + +test('api: JSON, ApiFehler, Netzfehler und roh (Review Focus 5)', async () => { + const fx = installFakeDom({ antworten: [{ body: { ok: 1 } }, { status: 409, body: { error: 'Schon da', code: 'bereits_eingeschaltet', field: 'x', extra: 1 } }, { status: 502, body: {} }] }) + try { + assert.deepEqual(await api('/api/x', { method: 'POST', body: { a: 1 } }), { ok: 1 }) + assert.deepEqual(fx.fetchLog[0], { method: 'POST', url: '/api/x', body: { a: 1 } }) + const e = await api('/api/x').catch((x) => x) + assert.ok(e instanceof ApiFehler); assert.deepEqual([e.status, e.code, e.message, e.field, e.body.extra], [409, 'bereits_eingeschaltet', 'Schon da', 'x', 1]) + assert.equal((await api('/api/x').catch((x) => x)).message, 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.') + globalThis.fetch = async () => { throw new TypeError('Failed to fetch') } + const n = await api('/api/x').catch((x) => x); assert.deepEqual([n.status, n.message], [0, 'Keine Verbindung. Bitte versuch es gleich noch einmal.']) + let init; globalThis.fetch = async (_u, i) => { init = i; return { ok: true, status: 200, json: async () => ({}) } } + const blob = new Blob(['abc']); await api('/api/up', { method: 'POST', roh: blob }) + assert.deepEqual([init.headers['Content-Type'], init.body], ['application/octet-stream', blob]) + } finally { fx.restore() } +}) + +test('api: session_revoked leitet genau einmal zur Anmeldung (letzter Test der Datei)', async () => { + const weg = { status: 401, body: { error: 'Sitzung beendet', code: 'session_revoked' } } + const fx = installFakeDom({ antworten: [weg, weg] }) + try { + await api('/api/x').catch(() => {}); assert.equal(fx.window.location.href, '/login.html?error=Sitzung%20beendet') + fx.window.location.href = 'anderswo'; await api('/api/x').catch(() => {}); assert.equal(fx.window.location.href, 'anderswo') + } finally { fx.restore() } +}) diff --git a/test/knHuelleQuelltext.test.js b/test/knHuelleQuelltext.test.js new file mode 100644 index 0000000..706cf8d --- /dev/null +++ b/test/knHuelleQuelltext.test.js @@ -0,0 +1,16 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, existsSync } from 'node:fs' + +const P1 = ['dom', 'api', 'routen', 'router', 'reiter', 'etage', 'zaehler', 'fristen-streifen', 'konto', 'leiste', 'huelle'].map((n) => `${n}.js`) +const pfad = (f) => new URL(`../public/neu/${f}`, import.meta.url) +const ohneKommentare = (t) => t.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') + +test('P1-Module: kein innerHTML, nur relative Importe, keine Verweise auf entfernte Seiten', () => { + for (const f of P1.filter((d) => existsSync(pfad(d)))) { + const q = ohneKommentare(readFileSync(pfad(f), 'utf8')) + assert.doesNotMatch(q, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|\beval\(/, f) + assert.doesNotMatch(q, /admin\.html|kalkulation\.html|\/api\/portal-admin/, f) + for (const m of q.matchAll(/from '([^']+)'/g)) assert.match(m[1], /^\.\//, `${f}: nur relative Importe`) + } +})