Kundenansicht neu P2: Hilfen, Testdaten und Quelltext-Regeln
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
385844192d
commit
91e5bca1c5
4 files changed
+537
No files matched your search
@@ -0,0 +1,165 @@
|
||||
/* Kundenansicht neu (P2): gemeinsame Hilfen der Ansichten. Formate, Bausteinklassen, Navigation, Ereignisse und kleine Merker.
|
||||
Kein DOM-Zugriff beim Import; document und window werden erst beim Aufruf gelesen. */
|
||||
|
||||
import { api } from './api.js'
|
||||
import { geheZu } from './router.js'
|
||||
import { hashFuerAnsicht } from './routen.js'
|
||||
import { safeUrl } from '../fn-util.js'
|
||||
|
||||
/** Klassen der Bausteine aus basis.css. */
|
||||
export const K = Object.freeze({
|
||||
knopf: 'kn-knopf',
|
||||
primaer: 'kn-knopf-primaer',
|
||||
zweit: 'kn-knopf-zweit',
|
||||
textknopf: 'kn-textknopf',
|
||||
feld: 'kn-feld',
|
||||
input: 'kn-input',
|
||||
select: 'kn-select',
|
||||
textarea: 'kn-textarea',
|
||||
check: 'kn-check',
|
||||
liste: 'kn-liste',
|
||||
zeile: 'kn-zeile',
|
||||
abschnitt: 'kn-abschnitt',
|
||||
meldung: 'kn-meldung',
|
||||
fehler: 'kn-fehler',
|
||||
leer: 'kn-leer',
|
||||
versteckt: 'hidden',
|
||||
pille: Object.freeze({
|
||||
grau: 'kn-pille kn-pille-grau',
|
||||
blau: 'kn-pille kn-pille-blau',
|
||||
orange: 'kn-pille kn-pille-orange',
|
||||
violett: 'kn-pille kn-pille-violett',
|
||||
gruen: 'kn-pille kn-pille-gruen',
|
||||
rot: 'kn-pille kn-pille-rot',
|
||||
}),
|
||||
})
|
||||
|
||||
export const ABO_NICHT_GELADEN = 'Deine Verträge konnten gerade nicht geladen werden. Bitte versuche es später noch einmal.'
|
||||
|
||||
/* ---------- Formate ---------- */
|
||||
|
||||
const ZAHL_2 = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
const ZAHL_1 = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
const ZAHL_STD = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||||
|
||||
/** Teile eines Zeitpunkts in Berliner Zeit; ungültige Werte ergeben null. */
|
||||
function berlinTeile(wert, extra = {}) {
|
||||
const d = wert instanceof Date ? wert : new Date(wert)
|
||||
if (!wert || Number.isNaN(d.getTime())) return null
|
||||
const teile = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hourCycle: 'h23', ...extra }).formatToParts(d)
|
||||
return Object.fromEntries(teile.map((t) => [t.type, t.value]))
|
||||
}
|
||||
|
||||
/** 50 → '50,00 €' (geschütztes Leerzeichen vor dem Euro-Zeichen). */
|
||||
export const fmtEuro = (euro) => `${ZAHL_2.format(Number(euro) || 0)} €`
|
||||
|
||||
/** 'TT.MM.JJJJ' in Berliner Zeit; ein Wert, der schon so aussieht, bleibt; leer oder ungültig '–'. */
|
||||
export function fmtDatum(wert) {
|
||||
if (typeof wert === 'string' && /^\d{2}\.\d{2}\.\d{4}$/.test(wert)) return wert
|
||||
const t = berlinTeile(wert, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
return t ? `${t.day}.${t.month}.${t.year}` : '–'
|
||||
}
|
||||
|
||||
/** 'TT.MM.JJ, HH:MM' in Berliner Zeit. */
|
||||
export function fmtDatumZeit(wert) {
|
||||
const t = berlinTeile(wert, { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
return t ? `${t.day}.${t.month}.${t.year}, ${t.hour}:${t.minute}` : '–'
|
||||
}
|
||||
|
||||
/** 'TT.MM.' in Berliner Zeit; ungültig ''. */
|
||||
export function fmtTagMonat(wert) {
|
||||
const t = berlinTeile(wert, { day: '2-digit', month: '2-digit' })
|
||||
return t ? `${t.day}.${t.month}.` : ''
|
||||
}
|
||||
|
||||
/** Minuten → '2,5 Std.' | '2 Std.' | '0,75 Std.' (Komma, höchstens zwei Stellen). */
|
||||
export const fmtStd = (minuten) => `${ZAHL_STD.format(Math.round(((Number(minuten) || 0) / 60) * 100) / 100)} Std.`
|
||||
|
||||
/** Minuten → '' | '45 min' | '2 h' | '2 h 30 min'. */
|
||||
export function fmtMinuten(minuten) {
|
||||
const m = Math.round(Number(minuten) || 0)
|
||||
if (m <= 0) return ''
|
||||
const h = Math.floor(m / 60)
|
||||
const rest = m % 60
|
||||
if (!h) return `${rest} min`
|
||||
return rest ? `${h} h ${rest} min` : `${h} h`
|
||||
}
|
||||
|
||||
/** Bytes → '12 B' | '1,5 KB' | '5,0 MB'. */
|
||||
export function fmtBytes(bytes) {
|
||||
const b = Number(bytes) || 0
|
||||
if (b < 1024) return `${Math.round(b)} B`
|
||||
if (b < 1024 * 1024) return `${ZAHL_1.format(b / 1024)} KB`
|
||||
return `${ZAHL_1.format(b / (1024 * 1024))} MB`
|
||||
}
|
||||
|
||||
/* ---------- Alt-Module, Navigation, Ereignisse ---------- */
|
||||
|
||||
/** Adapter für Alt-Module (auftrag-portal.js), die den Body als JSON-String schicken: das neue api() kodiert Objekte selbst. */
|
||||
export function legacyRequest(apiFn) {
|
||||
return (pfad, opts = {}) => {
|
||||
const { body } = opts
|
||||
if (typeof body !== 'string') return apiFn(pfad, { ...opts })
|
||||
let objekt = body
|
||||
try { objekt = JSON.parse(body) } catch { /* kein JSON: unverändert weitergeben */ }
|
||||
return apiFn(pfad, { ...opts, body: objekt })
|
||||
}
|
||||
}
|
||||
|
||||
/** Programmatisch navigieren (nur über den Router); wirft der Router, setzt der Hash die Adresse. */
|
||||
export function navigiere(hash, { ersetzen = false } = {}) {
|
||||
try {
|
||||
geheZu(hash, { ersetzen })
|
||||
} catch {
|
||||
window.location.hash = hash
|
||||
}
|
||||
}
|
||||
|
||||
/** Verknüpfung zu einer Ansicht für die Brücke der Hülle: href plus data-view (und data-project). */
|
||||
export function zuAnsicht(name, projektId = '') {
|
||||
const basis = hashFuerAnsicht(name) ?? '#'
|
||||
if (!projektId) return { href: basis, 'data-view': name }
|
||||
return { href: `${basis}${basis.includes('?') ? '&' : '?'}projekt=${encodeURIComponent(projektId)}`, 'data-view': name, 'data-project': projektId }
|
||||
}
|
||||
|
||||
/** Titel der Etage melden (die Hülle zeigt ihn im Kopf). */
|
||||
export function meldeTitel(el, text) {
|
||||
el.dispatchEvent(new CustomEvent('kn:titel', { bubbles: true, detail: { text } }))
|
||||
}
|
||||
|
||||
/** Zähler neu laden lassen: was = 'chat' | 'vertraege' | 'alle'. Ohne Dokument tut es nichts. */
|
||||
export function meldeZaehlerNeu(was) {
|
||||
if (typeof document === 'undefined' || typeof document.dispatchEvent !== 'function' || typeof CustomEvent === 'undefined') return
|
||||
document.dispatchEvent(new CustomEvent('kn:zaehler-neu', { bubbles: true, detail: { was } }))
|
||||
}
|
||||
|
||||
/* ---------- Merker zwischen Ansichten (einmalig) ---------- */
|
||||
|
||||
let hinweis = ''
|
||||
export function merkeHinweis(text) { hinweis = String(text ?? '') }
|
||||
export function nimmHinweis() { const t = hinweis; hinweis = ''; return t }
|
||||
|
||||
let chatVorgabe = ''
|
||||
export function setzeChatVorgabe(text) { chatVorgabe = String(text ?? '') }
|
||||
export function nimmChatVorgabe() { const t = chatVorgabe; chatVorgabe = ''; return t }
|
||||
|
||||
/* ---------- Abo ---------- */
|
||||
|
||||
/** GET /api/abo → Objekt oder null (jeder Fehler). */
|
||||
export async function ladeAbo() {
|
||||
try {
|
||||
const antwort = await api('/api/abo')
|
||||
return antwort && typeof antwort === 'object' ? antwort : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Gemerkte Funktion eines Wechsels (V11): nur wenn key und projectId nichtleere Texte sind. */
|
||||
export function wechselFunktionAus(sub) {
|
||||
const f = sub?.wechselFunktion
|
||||
if (!f || typeof f.key !== 'string' || !f.key || typeof f.projectId !== 'string' || !f.projectId) return null
|
||||
return { key: f.key, projectId: f.projectId }
|
||||
}
|
||||
|
||||
export { safeUrl }
|
||||
@@ -0,0 +1,87 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
fmtEuro, fmtDatum, fmtDatumZeit, fmtTagMonat, fmtStd, fmtMinuten, fmtBytes, legacyRequest, meldeZaehlerNeu, merkeHinweis, nimmHinweis,
|
||||
setzeChatVorgabe, nimmChatVorgabe, wechselFunktionAus, zuAnsicht, meldeTitel, K, ABO_NICHT_GELADEN, navigiere, ladeAbo, safeUrl,
|
||||
} from '../public/neu/ansicht-hilfen.js'
|
||||
import { mit, mitDom, abo, aboAntwort, me, projekt, rechnung, nachricht, angebot, ledger, anfrageGutschrift, VORSCHAU, VORSCHAU_SPEICHER, LIMITS, neueWurzel } from './support/knDaten.js'
|
||||
|
||||
test('Formate: Euro mit geschütztem Leerzeichen, Daten in Berliner Zeit', () => {
|
||||
assert.equal(fmtEuro(50), '50,00 €')
|
||||
assert.equal(fmtDatum('2026-10-17T10:00:00Z'), '17.10.2026')
|
||||
assert.equal(fmtDatum('17.10.2026'), '17.10.2026')
|
||||
assert.equal(fmtDatum(''), '–')
|
||||
assert.equal(fmtDatumZeit('2026-10-17T10:00:00Z'), '17.10.26, 12:00')
|
||||
assert.equal(fmtTagMonat('2026-10-17T21:59:59Z'), '17.10.')
|
||||
assert.equal(fmtTagMonat('2026-10-17T22:00:00Z'), '18.10.')
|
||||
assert.equal(fmtTagMonat('kaputt'), '')
|
||||
})
|
||||
|
||||
test('Formate: Stunden, Minuten, Bytes', () => {
|
||||
assert.deepEqual([150, 120, 45, 0].map(fmtStd), ['2,5 Std.', '2 Std.', '0,75 Std.', '0 Std.'])
|
||||
assert.deepEqual([0, 45, 120, 150].map(fmtMinuten), ['', '45 min', '2 h', '2 h 30 min'])
|
||||
assert.deepEqual([12, 1536, 5 * 1024 * 1024].map(fmtBytes), ['12 B', '1,5 KB', '5,0 MB'])
|
||||
})
|
||||
|
||||
test('legacyRequest: String-Body wird zum Objekt, Objekt und fehlender Body bleiben', async () => {
|
||||
const aufrufe = []
|
||||
const alt = legacyRequest(async (pfad, opts) => { aufrufe.push([pfad, opts]); return {} })
|
||||
await alt('/a', { method: 'POST', body: '{"x":1}' }); await alt('/b', { method: 'POST', body: { y: 2 } }); await alt('/c')
|
||||
assert.deepEqual(aufrufe, [['/a', { method: 'POST', body: { x: 1 } }], ['/b', { method: 'POST', body: { y: 2 } }], ['/c', {}]])
|
||||
})
|
||||
|
||||
test('meldeZaehlerNeu ohne Dokument tut nichts, Hinweis und Chat-Vorgabe gelten einmal', () => {
|
||||
assert.doesNotThrow(() => meldeZaehlerNeu('chat'))
|
||||
merkeHinweis('Gespeichert.'); assert.equal(nimmHinweis(), 'Gespeichert.'); assert.equal(nimmHinweis(), '')
|
||||
setzeChatVorgabe('Hallo'); assert.equal(nimmChatVorgabe(), 'Hallo'); assert.equal(nimmChatVorgabe(), '')
|
||||
})
|
||||
|
||||
test('wechselFunktionAus: nur vollständige Werte zählen (V11)', () => {
|
||||
assert.deepEqual(wechselFunktionAus({ wechselFunktion: { key: 'termine', projectId: 'p1', extra: 1 } }), { key: 'termine', projectId: 'p1' })
|
||||
for (const sub of [undefined, null, {}, { wechselFunktion: null }, { wechselFunktion: { key: 'termine' } }, { wechselFunktion: { key: '', projectId: 'p1' } }, { wechselFunktion: { key: 1, projectId: 'p1' } }]) assert.equal(wechselFunktionAus(sub), null)
|
||||
})
|
||||
|
||||
test('zuAnsicht ist die Brücke der Hülle, meldeTitel steigt zu den Eltern auf', () => mitDom((fx) => {
|
||||
assert.deepEqual(zuAnsicht('takeover'), { href: '#gutschrift', 'data-view': 'takeover' })
|
||||
assert.deepEqual(zuAnsicht('projekt', 'p1'), { href: '#projekt?projekt=p1', 'data-view': 'projekt', 'data-project': 'p1' })
|
||||
assert.equal(zuAnsicht('invoices').href, '#rechnungen')
|
||||
const aussen = fx.document.createElement('div'); const innen = fx.document.createElement('p'); aussen.append(innen); const titel = []
|
||||
aussen.addEventListener('kn:titel', (e) => titel.push(e.detail.text)); meldeTitel(innen, 'Zwei'); assert.deepEqual(titel, ['Zwei'])
|
||||
}))
|
||||
|
||||
test('Bausteinklassen K tragen die Klassen aus basis.css', () => {
|
||||
assert.equal(K.knopf, 'kn-knopf'); assert.equal(K.primaer, 'kn-knopf-primaer'); assert.equal(K.zweit, 'kn-knopf-zweit'); assert.equal(K.textknopf, 'kn-textknopf')
|
||||
assert.equal(K.versteckt, 'hidden'); assert.equal(K.pille.gruen, 'kn-pille kn-pille-gruen'); assert.equal(K.pille.rot, 'kn-pille kn-pille-rot')
|
||||
assert.match(ABO_NICHT_GELADEN, /nicht geladen werden/)
|
||||
})
|
||||
|
||||
test('ladeAbo liefert das Objekt, navigiere wirft ohne Router nicht', mit({ 'GET /api/abo': aboAntwort() }, async () => {
|
||||
const antwort = await ladeAbo()
|
||||
assert.equal(antwort.subscription, null)
|
||||
assert.equal(antwort.termsVersion, '2026-09-30-v5')
|
||||
assert.doesNotThrow(() => navigiere('#chat'))
|
||||
}))
|
||||
|
||||
test('ladeAbo bei Fehler: null', mit({ 'GET /api/abo': () => ({ status: 500, body: { error: 'kaputt' } }) }, async () => {
|
||||
assert.equal(await ladeAbo(), null)
|
||||
}))
|
||||
|
||||
test('Testdaten: Bauer liefern die Formen der Schnittstellen', () => {
|
||||
assert.equal(me().customer.email, 'anna@example.com')
|
||||
assert.equal(abo().productKey, 'wartung'); assert.equal(abo({ produkt: 'hosting' }).productKey, 'hosting')
|
||||
assert.equal(abo().status, 'active'); assert.equal(abo().storage.units, 0)
|
||||
assert.equal(aboAntwort().products.length, 2); assert.equal(aboAntwort().mailStorage.includedGb, 5)
|
||||
assert.equal(projekt().id.length > 0, true); assert.equal(rechnung().balance, 0); assert.equal(typeof nachricht().text, 'string')
|
||||
assert.equal(angebot().months > 0, true); assert.equal(ledger().balanceMinutes >= 0, true); assert.equal(typeof anfrageGutschrift().status, 'string')
|
||||
assert.equal(VORSCHAU.amountDueCents, 3000); assert.equal(VORSCHAU_SPEICHER.direction, 'down'); assert.equal(LIMITS.maxFiles, 3)
|
||||
assert.equal(safeUrl('javascript:alert(1)'), ''); assert.equal(safeUrl('https://a.de/x'), 'https://a.de/x')
|
||||
})
|
||||
|
||||
test('mit(): unbekannte Pfade antworten 404, Funktionen liefern Status und Body, Neuaufbau je Test', mit({ '/api/x': { a: 1 }, 'POST /api/y': (aufruf) => ({ status: 201, body: { echo: aufruf.body } }) }, async (fx) => {
|
||||
const w = neueWurzel(fx)
|
||||
assert.equal(w.parentNode, fx.document.body)
|
||||
const a = await fetch('/api/x'); assert.equal(a.status, 200); assert.deepEqual(await a.json(), { a: 1 })
|
||||
const b = await fetch('/api/y', { method: 'POST', body: JSON.stringify({ q: 1 }) }); assert.equal(b.status, 201); assert.deepEqual(await b.json(), { echo: { q: 1 } })
|
||||
const c = await fetch('/api/z'); assert.equal(c.status, 404); assert.deepEqual(await c.json(), { error: 'unbekannt' })
|
||||
const ev = new CustomEvent('x', { detail: 1 }); assert.equal(ev.detail, 1); ev.target = 'frei'; assert.equal(ev.target, 'frei')
|
||||
}))
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const WURZEL = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const NEU = path.join(WURZEL, 'public', 'neu')
|
||||
const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort']
|
||||
const WORTLAUT = ['ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift']
|
||||
const vorhanden = MODULE.filter((n) => existsSync(path.join(NEU, `${n}.js`)))
|
||||
|
||||
const ohneKommentare = (q) => q.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1')
|
||||
const lies = (n) => ohneKommentare(readFileSync(path.join(NEU, `${n}.js`), 'utf8'))
|
||||
|
||||
function dateienUnter(ordner, muster) {
|
||||
const aus = []
|
||||
for (const name of readdirSync(path.join(WURZEL, ordner))) {
|
||||
const pfad = path.join(WURZEL, ordner, name)
|
||||
if (statSync(pfad).isDirectory()) aus.push(...dateienUnter(path.join(ordner, name), muster))
|
||||
else if (muster.test(name)) aus.push(pfad)
|
||||
}
|
||||
return aus
|
||||
}
|
||||
|
||||
test('P2-Module: kein HTML-Einsetzen, nur relative Importe, keine Admin-Verweise', () => {
|
||||
for (const n of vorhanden) {
|
||||
const q = lies(n)
|
||||
assert.doesNotMatch(q, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|console\.log/, n)
|
||||
assert.doesNotMatch(q, /from\s+'\/|import\(\s*'\//, `${n}: absoluter Import`)
|
||||
assert.doesNotMatch(q, /admin\.html|kalkulation\.html|\/api\/portal-admin/, n)
|
||||
}
|
||||
})
|
||||
|
||||
test('P2-Module: Du-Form, nur die vier Wortlaut-Dateien behalten die Sie-Form (C14)', () => {
|
||||
for (const n of vorhanden.filter((x) => !WORTLAUT.includes(x))) assert.doesNotMatch(lies(n), /\b(Sie|Ihr(e[nmrs]?)?|Ihnen)\b/, n)
|
||||
})
|
||||
|
||||
test('public/neu/: kein Lila in irgendeiner Textdatei (die Prüfung lila liest jedes geladene Stylesheet samt ungenutzter Regeln)', () => {
|
||||
for (const pfad of dateienUnter('public/neu', /\.(css|html|js|mjs|svg|webmanifest)$/)) assert.doesNotMatch(readFileSync(pfad, 'utf8'), /#9d3dfe|rgba?\(\s*157\s*,\s*61\s*,\s*254/i, pfad)
|
||||
})
|
||||
@@ -0,0 +1,244 @@
|
||||
// Testdaten und Rahmen für die Ansichten von P2 (Kundenansicht neu). Formen laut server/routes/abo.js (GET /api/abo),
|
||||
// routes/customerData.js mit services/customerInvoices.js (Rechnungen), routes/projects.js, routes/chat.js und services/takeoverCredits.js.
|
||||
import { installFakeDom } from './fakeDom.js'
|
||||
|
||||
const ANTWORTEN = 400 // so viele Antwortfunktionen legt der Fake-fetch bereit; danach antwortet fakeDom mit 200 {}
|
||||
|
||||
// fakeDom schreibt target und ergebnisse in das Ereignis; ein echtes CustomEvent hat nur lesbare Felder.
|
||||
class EreignisAttrappe {
|
||||
constructor(typ, init = {}) {
|
||||
this.type = typ
|
||||
this.detail = init.detail ?? null
|
||||
this.bubbles = init.bubbles ?? false
|
||||
this.cancelable = init.cancelable ?? false
|
||||
this.target = null
|
||||
this.currentTarget = null
|
||||
this.defaultPrevented = false
|
||||
this.stopped = false
|
||||
this.ergebnisse = []
|
||||
}
|
||||
|
||||
preventDefault() { this.defaultPrevented = true }
|
||||
|
||||
stopPropagation() { this.stopped = true }
|
||||
}
|
||||
|
||||
// fakeDom kennt auf dem Dokument keine Ereignisse; Tests der Zähler-Meldung brauchen sie.
|
||||
function ereignisseFuerDokument(document) {
|
||||
const hoerer = new Map()
|
||||
document.addEventListener = (typ, fn) => { if (!hoerer.has(typ)) hoerer.set(typ, []); hoerer.get(typ).push(fn) }
|
||||
document.removeEventListener = (typ, fn) => { const l = hoerer.get(typ); if (l?.includes(fn)) l.splice(l.indexOf(fn), 1) }
|
||||
document.dispatchEvent = (ev) => {
|
||||
ev.target = document
|
||||
ev.currentTarget = document
|
||||
for (const fn of [...(hoerer.get(ev.type) ?? [])]) ev.ergebnisse.push(fn.call(document, ev))
|
||||
return !ev.defaultPrevented
|
||||
}
|
||||
}
|
||||
|
||||
function mitAttrappen(fx) {
|
||||
const vorher = Object.getOwnPropertyDescriptor(globalThis, 'CustomEvent')
|
||||
Object.defineProperty(globalThis, 'CustomEvent', { value: EreignisAttrappe, writable: true, configurable: true, enumerable: true })
|
||||
ereignisseFuerDokument(fx.document)
|
||||
return () => {
|
||||
if (vorher) Object.defineProperty(globalThis, 'CustomEvent', vorher)
|
||||
else delete globalThis.CustomEvent
|
||||
}
|
||||
}
|
||||
|
||||
const ohneQuery = (url) => String(url).split('?')[0]
|
||||
|
||||
/** Antwort für einen Aufruf nachschlagen: 'METHOD url', url, 'METHOD pfad', pfad; unbekannt → 404. */
|
||||
function beantworte(tabelle, aufruf) {
|
||||
const pfad = ohneQuery(aufruf.url)
|
||||
const schluessel = [`${aufruf.method} ${aufruf.url}`, aufruf.url, `${aufruf.method} ${pfad}`, pfad].find((k) => Object.hasOwn(tabelle, k))
|
||||
if (!schluessel) return { status: 404, body: { error: 'unbekannt' } }
|
||||
const wert = tabelle[schluessel]
|
||||
return typeof wert === 'function' ? wert(aufruf) : { status: 200, body: wert }
|
||||
}
|
||||
|
||||
/** Test mit Fake-DOM und Fake-fetch. tabelle: { 'GET /api/abo': Objekt | (aufruf) => ({ status, body }) }; der Test bekommt fx (siehe fakeDom.js) mit fx.tabelle. */
|
||||
export const mit = (tabelle, fn) => async () => {
|
||||
const antworten = Array.from({ length: ANTWORTEN }, () => (aufruf) => beantworte(tabelle, aufruf))
|
||||
const fx = installFakeDom({ antworten })
|
||||
fx.tabelle = tabelle
|
||||
const zurueck = mitAttrappen(fx)
|
||||
try {
|
||||
return await fn(fx)
|
||||
} finally {
|
||||
zurueck()
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
/** Wie mit() ohne Antworten und ohne Warten: fn(fx) läuft sofort (für synchrone Tests). */
|
||||
export const mitDom = (fn) => {
|
||||
const fx = installFakeDom({ antworten: [] })
|
||||
const zurueck = mitAttrappen(fx)
|
||||
try {
|
||||
return fn(fx)
|
||||
} finally {
|
||||
zurueck()
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
/** Leere Wurzel (div) im Dokument. */
|
||||
export const neueWurzel = (fx) => {
|
||||
const wurzel = fx.document.createElement('div')
|
||||
fx.document.body.append(wurzel)
|
||||
return wurzel
|
||||
}
|
||||
|
||||
export const VORSCHAU = { prorationDate: 1760000000, amountDueCents: 3000, creditCents: 1500, monthlyCents: 5000, nextChargeAt: '2026-11-16T00:00:00.000Z', minTermEnd: '2027-10-16T00:00:00.000Z' }
|
||||
export const VORSCHAU_SPEICHER = { direction: 'down', units: 1, totalGb: 10, monthlyCents: 100, deltaMonthlyCents: -100, prorationCents: 0, nextChargeAt: '2026-11-16T00:00:00.000Z', effectiveAt: '2026-11-01T00:00:00.000Z' }
|
||||
export const LIMITS = { maxFiles: 3, maxFileBytes: 8388608, maxTotalBytes: 10485760 }
|
||||
|
||||
/** Antwort von GET /api/auth/me für einen Kunden; over.customer ergänzt den Kunden, alles andere (viewAs, viewId …) die Antwort. */
|
||||
export const me = ({ customer = {}, ...over } = {}) => ({
|
||||
authenticated: true,
|
||||
role: 'customer',
|
||||
customer: { id: 'c1', code: 'K-1001', name: 'Anna Beispiel', companyName: '', email: 'anna@example.com', phone: '', location: '', customerStatus: 'customer', portalAccessEnabled: true, ...customer },
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Eintrag aus GET /api/projects. */
|
||||
export const projekt = (over = {}) => ({
|
||||
id: 'p1',
|
||||
projectName: 'Bäckerei Muster',
|
||||
subdomain: 'baeckerei',
|
||||
previewUrl: 'https://baeckerei.project.webklar.com',
|
||||
liveDomain: '',
|
||||
status: 'in_progress',
|
||||
provisioningStatus: 'ready',
|
||||
updatedAt: '2026-10-14T09:00:00.000Z',
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Eintrag aus GET /api/invoices (Stripe-Form, Beträge in Euro). */
|
||||
export const rechnung = (over = {}) => ({
|
||||
id: 'in_1',
|
||||
source: 'stripe',
|
||||
number: 'RE-2026-001',
|
||||
date: '2026-10-01',
|
||||
dueDate: '2026-10-15',
|
||||
amount: 50,
|
||||
balance: 0,
|
||||
status: 'Bezahlt',
|
||||
paid: true,
|
||||
cancelled: false,
|
||||
overdue: false,
|
||||
portalUrl: '',
|
||||
hasPdf: true,
|
||||
projekt: '',
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Nachricht aus GET /api/chat/messages. */
|
||||
export const nachricht = (over = {}) => ({
|
||||
id: 'm1',
|
||||
sender: 'customer',
|
||||
senderName: 'Anna Beispiel',
|
||||
text: 'Hallo, ich hätte eine Frage.',
|
||||
attachments: [],
|
||||
createdAt: '2026-10-14T08:30:00.000Z',
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Angebot (offerView) aus GET /api/abo → offers. */
|
||||
export const angebot = (over = {}) => ({
|
||||
id: 'o1',
|
||||
ref: 'A-1',
|
||||
createdAt: '2026-10-01T10:00:00.000Z',
|
||||
status: 'offen',
|
||||
scope: 'abo',
|
||||
productKey: 'wartung',
|
||||
months: 2,
|
||||
monthlyPriceCents: 2500,
|
||||
regularPriceCents: 5000,
|
||||
savingsCents: 5000,
|
||||
text: 'Erste 2 Monate je 25,00 € statt 50,00 €',
|
||||
validUntil: '2026-11-30T00:00:00.000Z',
|
||||
note: '',
|
||||
...over,
|
||||
})
|
||||
|
||||
/** subscription aus GET /api/abo (publicSubscription). produkt: 'wartung' (Vorgabe) oder 'hosting'. */
|
||||
export const abo = ({ produkt = 'wartung', ...over } = {}) => ({
|
||||
status: 'active',
|
||||
active: true,
|
||||
paymentProblem: false,
|
||||
startedAt: '2026-09-01T10:00:00.000Z',
|
||||
currentPeriodEnd: '2026-11-01T00:00:00.000Z',
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: null,
|
||||
minTermEnd: produkt === 'hosting' ? '2027-09-01T10:00:00.000Z' : '2027-09-01T10:00:00.000Z',
|
||||
endedAt: null,
|
||||
priceAmount: produkt === 'hosting' ? 2000 : 5000,
|
||||
startChoice: null,
|
||||
serviceStartsAt: null,
|
||||
offerText: null,
|
||||
productKey: produkt,
|
||||
termsVersion: '2026-09-30-v5',
|
||||
termStartAt: null,
|
||||
termsUrl: '/abo-bedingungen.html',
|
||||
termsPdfUrl: '/WEBklar-Abo-Vertragsunterlagen.pdf',
|
||||
storage: { units: 0, pendingSetup: false, pendingReduction: null },
|
||||
canChangeStorage: true,
|
||||
canSwitch: produkt === 'hosting',
|
||||
switchPending: null,
|
||||
withdrawals: [],
|
||||
withdrawalOpen: false,
|
||||
withdrawalUntil: null,
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Stundenkonto (computeLedger). */
|
||||
export const ledger = (over = {}) => ({
|
||||
balanceMinutes: 150,
|
||||
grantedMinutes: 240,
|
||||
usedMinutes: 90,
|
||||
overageMinutes: 0,
|
||||
expiredMinutes: 0,
|
||||
nextExpiry: { minutes: 150, at: '2027-01-01T00:00:00.000Z' },
|
||||
credits: [{ grantedAt: '2026-10-01T00:00:00.000Z', expiresAt: '2027-01-01T00:00:00.000Z', minutes: 240, remaining: 150, expired: false }],
|
||||
usage: [{ date: '2026-10-10T00:00:00.000Z', minutes: 90, coveredMinutes: 90, overageMinutes: 0, description: 'Textänderungen' }],
|
||||
...over,
|
||||
})
|
||||
|
||||
const PRODUKTE = [
|
||||
{ key: 'hosting', name: 'WEBklar Hosting-Abo', amountCents: 2000, minTermMonths: 12, minutesPerMonth: 0, features: [['Hosting', 'Deine Website online']] },
|
||||
{ key: 'wartung', name: 'WEBklar Wartungs-Abo', amountCents: 5000, minTermMonths: 12, minutesPerMonth: 60, features: [['Wartung', 'Änderungen und Pflege']] },
|
||||
]
|
||||
|
||||
/** Ganze Antwort von GET /api/abo. over.subscription ersetzt das Abo (null = kein Abo). */
|
||||
export const aboAntwort = (over = {}) => ({
|
||||
available: true,
|
||||
testAccount: false,
|
||||
termsVersion: '2026-09-30-v5',
|
||||
lateStartAt: '2026-10-30T00:00:00.000Z',
|
||||
products: PRODUKTE.map((p) => ({ ...p, features: p.features.map((f) => [...f]) })),
|
||||
mailStorage: { includedGb: 5, gbPerUnit: 5, unitPriceCents: 100, maxUnits: 10 },
|
||||
offers: { hosting: null, wartung: null },
|
||||
ledger: null,
|
||||
subscription: null,
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Antrag aus GET /api/abo/takeover-credits → requests (customerView). */
|
||||
export const anfrageGutschrift = (over = {}) => ({
|
||||
id: 'g1',
|
||||
ref: 'G-1',
|
||||
createdAt: '2026-10-05T10:00:00.000Z',
|
||||
services: 'Domain',
|
||||
provider: 'Anbieter GmbH',
|
||||
domain: 'beispiel.de',
|
||||
invoiceAmountCents: 1200,
|
||||
billingMonths: 12,
|
||||
paidUntil: '2027-03-01T00:00:00.000Z',
|
||||
status: 'offen',
|
||||
question: '',
|
||||
amountCents: 0,
|
||||
decisionNote: '',
|
||||
...over,
|
||||
})
|
||||
Reference in new issue
Block a user