diff --git a/public/auftrag.css b/public/auftrag.css index 4899a59..4b1e49b 100644 --- a/public/auftrag.css +++ b/public/auftrag.css @@ -43,7 +43,7 @@ body.ab-seite { } .ab-seite .ab-fuss { flex-shrink: 0; padding: 8px 0 max(24px, env(safe-area-inset-bottom)); font-size: 13px; color: var(--ab-weich); } .ab-seite .ab-fuss .ab-innen { display: flex; gap: 16px; flex-wrap: wrap; } -.ab-seite .ab-fuss a { color: var(--ab-weich); display: inline-block; min-height: 44px; line-height: 44px; } +.ab-seite .ab-fuss a { color: var(--ab-weich); display: inline-flex; align-items: center; min-height: 44px; } /* ------------------------------------------------------------------ Inhalt --- */ .ab-seite .ab-pille, .ab-portal .ab-pille { @@ -71,7 +71,7 @@ body.ab-seite { .ab-seite .ab-gesamt strong, .ab-portal .ab-gesamt strong { font-weight: 800; } .ab-seite .ab-hinweis, .ab-portal .ab-hinweis { margin: 4px 0 0; font-size: 0.85rem; color: var(--ab-weich); } .ab-seite .ab-links, .ab-portal .ab-links { list-style: none; margin: 0 0 16px; padding: 0; } -.ab-seite .ab-links a, .ab-portal .ab-links a { display: inline-block; min-height: 44px; line-height: 44px; font-weight: 600; } +.ab-seite .ab-links a, .ab-portal .ab-links a { display: flex; align-items: center; min-height: 44px; line-height: 1.35; padding: 4px 0; font-weight: 600; } .ab-seite .ab-bestaetigt, .ab-portal .ab-bestaetigt { background: var(--ab-ok-bg); color: var(--ab-ok-text); border-radius: 12px; padding: 12px 16px; margin: 0 0 16px; } diff --git a/test/ui/auftraege-kundenportal.ui.mjs b/test/ui/auftraege-kundenportal.ui.mjs new file mode 100644 index 0000000..9ba1197 --- /dev/null +++ b/test/ui/auftraege-kundenportal.ui.mjs @@ -0,0 +1,286 @@ +// 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)