Files
Webklar-Kundenbereich/test/ui/auftraege-kundenportal.ui.mjs
T

287 lines
19 KiB
JavaScript

// Playwright-Prüfung der Kundenseiten zu Aufträgen (Strang L6): auftrag.html, Abschnitt „Aufträge“ im Dashboard, widerrufen.html.
// Statischer Server für public/ + page.route-Nachbau der /api-Antworten; nichts verlässt den Rechner. Nicht Teil von npm test.
// Aufruf: free -m; flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock nice node test/ui/auftraege-kundenportal.ui.mjs
// Bildschirmfotos: $SHOTS (Standard: Scratchpad auftraege-l6/). Exit-Code 1, wenn eine Prüfung scheitert.
import http from 'node:http'
import { readFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
import { TOKEN, ab, privat, bestaetigt, eintrag } from '../auftraegeKundenportalDaten.js'
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const SHOTS = process.env.SHOTS || '/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-l6'
await mkdir(SHOTS, { recursive: true })
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', '.woff2': 'font/woff2' }
const server = http.createServer(async (req, res) => {
try {
const datei = path.join(pub, new URL(req.url, 'http://x').pathname)
if (!datei.startsWith(pub + path.sep)) throw new Error('außerhalb von public/')
res.writeHead(200, { 'content-type': TYPES[path.extname(datei)] || 'application/octet-stream' })
res.end(await readFile(datei))
} catch {
res.writeHead(404)
res.end('nicht gefunden')
}
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
let failed = 0
const check = (name, ok, detail = '') => {
console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
if (!ok) failed += 1
}
const norm = (text) => String(text).replace(/\s+/g, ' ').trim() // auch das geschützte Leerzeichen vor „€“
const laufe = async (name, fn) => {
try {
await fn()
} catch (err) {
check(name, false, String(err.message).split('\n')[0])
}
}
let aufrufe = [] // alle /api-Anfragen der laufenden Prüfung
async function mocke(page, handler) {
aufrufe = []
await page.unroute('**/api/**')
await page.route('**/api/**', async (route) => {
const req = route.request()
const roh = req.postData()
aufrufe.push({ pfad: new URL(req.url()).pathname, methode: req.method(), body: roh ? JSON.parse(roh) : undefined })
const antwort = await handler(aufrufe.at(-1))
if (antwort === 'abbrechen') return route.abort()
const { status = 200, body = {} } = antwort || {}
return route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
})
}
const GET_AB = `/api/auftrag/${TOKEN}`
const POST_AB = `/api/auftrag/${TOKEN}/bestaetigen`
const KUNDE = { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' }
const RECHNUNGEN = [
{ id: 'in_1', source: 'stripe', number: 'WEBKLAR-0009', date: '2026-10-06', dueDate: '2026-10-20', amount: 500, balance: 500, status: 'Offen', paid: false, cancelled: false, overdue: false, portalUrl: 'https://invoice.stripe.test/i/in_1', hasPdf: true, projekt: 'Website Anelia Wolf' },
{ id: 'in_2', source: 'stripe', number: 'H96T-0004', date: '2026-10-01', dueDate: '', amount: 25, balance: 0, status: 'Bezahlt', paid: true, cancelled: false, overdue: false, portalUrl: '', hasPdf: true, projekt: '' },
]
const AUFTRAEGE = [
eintrag({ id: 'ab3', nummer: 'AB-2026-0003' }),
eintrag({ id: 'ab2', ...bestaetigt(privat()), nummer: 'AB-2026-0002', titel: 'Logo und Visitenkarten', kannBestaetigen: false }),
eintrag({ id: 'ab1', nummer: 'AB-2026-0001', titel: 'Wartung Oktober', status: 'storniert', kannBestaetigen: false, art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.' }),
]
const dashboardApi = (me) => ({ pfad, methode }) => {
if (pfad === '/api/auth/me') return { body: me }
if (pfad === '/api/invoices') return { body: { invoices: RECHNUNGEN, incomplete: false } }
// wie L5: in der Kundenansicht ist kannBestaetigen immer false
if (pfad === '/api/auftraege') return { body: { aktiv: true, auftraege: AUFTRAEGE.map((a) => (me.viewAs ? { ...a, kannBestaetigen: false } : a)) } }
if (methode === 'POST' && pfad === '/api/auftraege/ab3/bestaetigen') return { body: { auftrag: bestaetigt(ab({ nummer: 'AB-2026-0003' })) } }
if (pfad === '/api/projects') return { body: { projects: [] } }
if (pfad === '/api/features') return { body: { features: [] } }
if (pfad === '/api/activity') return { body: { tickets: [] } }
if (pfad === '/api/contact') return { body: { name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' } }
if (pfad === '/api/chat/unread-count') return { body: { unread: 0 } }
if (pfad === '/api/chat/messages') return { body: { messages: [], unread: 0 } }
return { body: {} }
}
const postBody = () => JSON.stringify(aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body)
const browser = await chromium.launch().catch(() => chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome' }))
try {
for (const [label, viewport, mobil] of [['handy', { width: 390, height: 844 }, true], ['desktop', { width: 1440, height: 900 }, false]]) {
const ctx = await browser.newContext({ viewport, deviceScaleFactor: mobil ? 2 : 1, isMobile: mobil, hasTouch: mobil, locale: 'de-DE', timezoneId: 'Europe/Berlin' })
const page = await ctx.newPage()
page.setDefaultTimeout(30000)
const jsFehler = []
page.on('pageerror', (e) => jsFehler.push(e.message))
const foto = async (name) => {
// Einblend-Animationen des Dashboards abwarten; das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile:
// darum das Fenster kurz auf die Seitenhöhe ziehen (höchstens 3200 px)
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
const fenster = page.viewportSize()
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3200))
await page.setViewportSize({ width: fenster.width, height: hoehe })
await page.screenshot({ path: `${SHOTS}/${name}-${label}.png`, fullPage: true })
await page.setViewportSize(fenster)
}
const zustand = (z) => page.waitForSelector(`#ab-wurzel[data-zustand="${z}"]`)
const ohneUeberlauf = (selektor) => page.evaluate((s) => { const el = document.querySelector(s); return el.scrollWidth <= el.clientWidth + 1 }, selektor)
const oeffne = async (pfad, handler) => {
await mocke(page, handler)
await page.goto('about:blank') // frisch laden, auch wenn sich nur das Fragment unterscheidet
await page.goto(`${base}${pfad}`)
}
const oeffneAuftrag = (handler, adresse = `#t=${TOKEN}`) => oeffne(`/auftrag.html${adresse}`, handler) // K31 verlinkt #t=
const alsAb = (a, neu) => ({ pfad, methode }) => {
if (methode === 'GET' && pfad === GET_AB) return { body: { auftrag: a } }
if (methode === 'POST' && pfad === POST_AB) return { body: { auftrag: neu } }
}
await laufe(`${label} unternehmen`, async () => {
await oeffneAuftrag(alsAb(ab(), bestaetigt(ab())))
await zustand('offen')
check(`${label} offen_titel`, norm(await page.textContent('h1')) === 'Auftragsbestätigung AB-2026-0002')
check(`${label} formular_sie_form`, norm(await page.textContent('legend')) === 'Ihre Bestätigung')
check(`${label} paragraf_19_aus_der_api`, norm(await page.textContent('[data-hinweis]')) === 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
check(`${label} offen_positionen_und_summe`, (await page.locator('[data-position]').count()) === 2 && norm(await page.innerText('[data-gesamt]')) === 'Gesamtpreis 800,00 €')
check(`${label} offen_links`, (await page.getAttribute('[data-link="pdf"]', 'href')) === `/api/auftrag/${TOKEN}/pdf` && (await page.getAttribute('[data-link="agb"]', 'href')) === '/rechtliches/agb-2026-09-30.pdf')
check(`${label} offen_kein_zweites_haekchen`, (await page.locator('[data-feld="fruehbeginn"]').count()) === 0)
check(`${label} fusszeile`, (await page.locator('footer a').allInnerTexts()).join('|') === 'Datenschutz|Impressum')
check(`${label} kein_seitlicher_ueberlauf`, await ohneUeberlauf('html'))
const knopf = page.locator('[data-aktion="bestaetigen"]')
check(`${label} knopf_gesperrt_ohne_haken_und_gross_genug`, (await knopf.isDisabled()) && (await knopf.boundingBox()).height >= 44)
await foto('auftrag-offen-unternehmen')
await page.check('[data-feld="bestaetigung"]')
check(`${label} knopf_frei_mit_haken`, await knopf.isEnabled())
await knopf.click()
await zustand('bestaetigt')
check(`${label} post_koerper`, postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false }))
check(`${label} bestaetigt_zeit`, norm(await page.innerText('[data-bestaetigt]')) === 'Bestätigt am 07.10.2026, 10:15 Uhr')
check(`${label} token_nicht_im_text`, !(await page.innerText('body')).includes(TOKEN))
})
await laufe(`${label} rueckfall`, async () => {
await oeffneAuftrag(alsAb(ab()), `?t=${TOKEN}`)
await zustand('offen')
check(`${label} rueckfall_t_in_der_suche`, aufrufe.some((a) => a.methode === 'GET' && a.pfad === GET_AB))
})
await laufe(`${label} privat`, async () => {
await oeffneAuftrag(alsAb(privat(), bestaetigt(privat())))
await zustand('offen')
const frueh = page.locator('[data-feld="fruehbeginn"]')
check(`${label} privat_zweites_haekchen_frei_waehlbar`, (await frueh.isVisible()) && !(await frueh.isChecked()))
check(`${label} privat_kein_seitlicher_ueberlauf`, await ohneUeberlauf('html'))
await foto('auftrag-offen-privat')
await page.check('[data-feld="bestaetigung"]')
await page.click('[data-aktion="bestaetigen"]')
await zustand('bestaetigt')
check(`${label} privat_ohne_fruehbeginn`, postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false }))
check(`${label} privat_fruehester_beginn`, norm(await page.innerText('[data-bestaetigt]')) === 'Bestätigt am 07.10.2026, 10:15 Uhr Wir beginnen frühestens am 21.10.2026.')
await foto('auftrag-bestaetigt-privat')
})
await laufe(`${label} aufwand`, async () => {
await oeffneAuftrag(alsAb(ab({ art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten. Geschätzter Umfang: etwa 10 Stunden (unverbindlich).', zahlungsplanText: 'Monatlich nach Aufwand.' })))
await zustand('offen')
check(`${label} aufwand_text_statt_tabelle`, (await page.locator('[data-aufwand]').count()) === 1 && (await page.locator('table').count()) === 0)
await foto('auftrag-aufwand')
})
await laufe(`${label} fehlerzustaende`, async () => {
await oeffneAuftrag(() => ({ status: 404, body: { error: 'Dieser Link ist ungültig oder abgelaufen.' } }))
await zustand('ungueltig')
check(`${label} ungueltig_kontaktweg`, (await page.locator('a[href="mailto:support@webklar.com"]').count()) === 1)
await foto('auftrag-ungueltig')
await oeffneAuftrag(() => ({ status: 410, body: { error: 'Diese Auftragsbestätigung wurde ersetzt.' } }))
await zustand('beendet')
check(`${label} beendet_titel`, norm(await page.textContent('h1')) === 'Diese Auftragsbestätigung wurde ersetzt.')
await foto('auftrag-beendet')
await oeffneAuftrag(() => ({ status: 429, body: {} }))
await zustand('limit')
let versuche = 0
await oeffneAuftrag(({ pfad }) => {
if (pfad !== GET_AB) return undefined
versuche += 1
return versuche === 1 ? { status: 500, body: { error: 'Interner Fehler.' } } : { body: { auftrag: ab() } }
})
await zustand('fehler')
await foto('auftrag-fehler')
await page.click('[data-aktion="erneut"]')
await zustand('offen')
await oeffneAuftrag(() => 'abbrechen')
await zustand('fehler')
check(`${label} netzfehler_ohne_browsertext`, !/Failed to fetch|NetworkError|fetch failed/i.test(await page.innerText('body')))
await oeffne('/auftrag.html', () => ({}))
await zustand('ungueltig')
check(`${label} ohne_token_keine_anfrage`, aufrufe.length === 0)
})
await laufe(`${label} portal`, async () => {
await oeffne('/dashboard.html#rechnungen', dashboardApi({ authenticated: true, customer: KUNDE }))
await page.waitForSelector('#orders-section:not(.hidden) .ab-karte')
check(`${label} portal_reihenfolge`, JSON.stringify(await page.$$eval('.ab-karte', (k) => k.map((x) => x.getAttribute('data-auftrag')))) === JSON.stringify(['ab3', 'ab2', 'ab1']))
check(`${label} portal_du_form`, norm(await page.textContent('#orders-section > p')) === 'Deine Auftragsbestätigungen. Offene Aufträge bestätigst du hier mit einem Klick.' && norm(await page.textContent('.ab-karte[data-auftrag="ab3"] legend')) === 'Deine Bestätigung')
check(`${label} portal_projekt_unter_rechnungsnummer`, norm(await page.textContent('.inv-no .inv-project')) === 'Website Anelia Wolf')
check(`${label} portal_offene_ab_aufgeklappt`, (await page.locator('.ab-karte[data-auftrag="ab3"] details').getAttribute('open')) !== null)
check(`${label} portal_kein_seitlicher_ueberlauf`, await ohneUeberlauf('#orders-section'))
await foto('portal-auftraege')
await page.check('.ab-karte[data-auftrag="ab3"] [data-feld="bestaetigung"]')
await page.click('.ab-karte[data-auftrag="ab3"] [data-aktion="bestaetigen"]')
await page.waitForSelector('.ab-karte[data-auftrag="ab3"] [data-bestaetigt]')
check(`${label} portal_post_und_status`, aufrufe.some((a) => a.methode === 'POST' && a.pfad === '/api/auftraege/ab3/bestaetigen') && postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false }) && norm(await page.textContent('.ab-karte[data-auftrag="ab3"] .ab-pille')) === 'Bestätigt')
})
await laufe(`${label} ansichtsmodus`, async () => {
const me = { authenticated: true, role: 'admin', viewAs: true, viewId: 'a'.repeat(32), viewExpiresAt: Date.now() + 3_600_000, admin: { name: 'Andrej', email: 'andrej@webklar.com', isAdmin: false }, customer: KUNDE }
await oeffne('/dashboard.html#rechnungen', dashboardApi(me))
await page.waitForSelector('#orders-section:not(.hidden) .ab-karte')
const karte = '.ab-karte[data-auftrag="ab3"]'
check(`${label} ansicht_gesperrt`, (await page.locator(`${karte} [data-aktion="bestaetigen"]`).isDisabled()) && (await page.locator(`${karte} [data-feld="bestaetigung"]`).isDisabled()))
check(`${label} ansicht_hinweis`, norm(await page.textContent(`${karte} [data-gesperrt]`)) === 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.')
await foto('portal-auftraege-ansichtsmodus')
})
await laufe(`${label} widerruf`, async () => {
const api = ({ pfad, methode }) => {
if (pfad === '/api/auth/me') return { body: { authenticated: false } }
if (methode === 'POST' && pfad === '/api/abo/public/withdraw') return { body: { receivedAt: '2026-10-07T08:15:00.000Z' } }
}
const oeffneWiderruf = async () => {
await oeffne('/widerrufen.html', api)
await page.waitForFunction(() => document.getElementById('ab-nummer-wrap')?.classList.contains('hidden'))
}
const absenden = async (nummer) => {
await page.check('input[name="order"][value="projekt"]')
await page.fill('#name', 'Berta Berg')
await page.fill('#email', 'b@example.test')
if (nummer !== undefined) await page.fill('#abNummer', nummer)
await page.click('button[type="submit"]')
}
await oeffneWiderruf()
check(`${label} widerruf_feld_zuerst_versteckt`, !(await page.locator('#ab-nummer-wrap').isVisible()))
await absenden('AB-2026-01')
check(`${label} widerruf_feld_nur_bei_projektauftrag`, await page.locator('#ab-nummer-wrap').isVisible())
check(`${label} widerruf_unpassende_nummer_gestoppt`, aufrufe.every((a) => a.methode !== 'POST') && norm(await page.textContent('#form-error')).startsWith('Die Nummer sieht anders aus'))
await foto('widerrufen-projekt')
await page.fill('#abNummer', 'ab 2026 0001')
await page.click('button[type="submit"]')
await page.waitForSelector('#done:not(.hidden)')
const mitNummer = aufrufe.find((a) => a.methode === 'POST')?.body
check(`${label} widerruf_nummer_vereinheitlicht`, mitNummer?.order === 'projekt' && mitNummer.abNummer === 'AB-2026-0001', JSON.stringify(mitNummer))
check(`${label} widerruf_bestaetigung_nennt_nummer`, norm(await page.textContent('#done-list')).includes('Nummer der AuftragsbestätigungAB-2026-0001'))
await oeffneWiderruf()
await absenden() // ohne Nummer: freiwillig
await page.waitForSelector('#done:not(.hidden)')
const ohneNummer = aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body
check(`${label} widerruf_ohne_nummer_geht_durch`, ohneNummer?.order === 'projekt' && ohneNummer.abNummer === '', JSON.stringify(ohneNummer))
await oeffneWiderruf()
await page.fill('#name', 'Berta Berg')
await page.fill('#email', 'b@example.test')
await page.click('button[type="submit"]')
await page.waitForSelector('#done:not(.hidden)')
const andere = aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body
check(`${label} widerruf_andere_art_ohne_nummer`, andere?.order === 'abschluss' && andere.abNummer === '', JSON.stringify(andere))
})
check(`${label} keine_js_fehler`, jsFehler.length === 0, jsFehler.join(' | '))
await ctx.close()
}
// Dunkler Modus (Systemeinstellung): die Seite folgt den dunklen Farben der Mailvorlagen
const dunkel = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'de-DE', colorScheme: 'dark' })
const seite = await dunkel.newPage()
await laufe('dunkel', async () => {
await mocke(seite, ({ pfad }) => (pfad === GET_AB ? { body: { auftrag: privat() } } : undefined))
await seite.goto(`${base}/auftrag.html#t=${TOKEN}`)
await seite.waitForSelector('#ab-wurzel[data-zustand="offen"]')
check('dunkel hintergrund', (await seite.evaluate(() => getComputedStyle(document.body).backgroundColor)) === 'rgb(22, 22, 21)')
await seite.screenshot({ path: `${SHOTS}/auftrag-offen-privat-dunkel-handy.png`, fullPage: true })
})
await dunkel.close()
} finally {
await browser.close()
server.close()
}
console.log(failed ? `${failed} Check(s) fehlgeschlagen` : 'alle Checks ok')
process.exit(failed ? 1 : 0)