// @ts-check // Gemeinsame Hilfsfunktionen für die Steuerkram-E2E-Tests. // // Grundsatz: Die Tests laufen gegen eine Instanz mit echten Arbeitsdaten. // Sie legen deshalb ausschließlich eigene, eindeutig markierte Datensätze an // (siehe uniqueTag) und räumen diese im Teardown wieder ab. Bestehende Daten // werden nie verändert oder gelöscht. const { expect } = require('@playwright/test') /** Präfix, an dem sich Testdaten jederzeit erkennen und aufräumen lassen. */ const TAG_PREFIX = 'E2E-TEST' let counter = 0 /** Eindeutiger Marker pro Datensatz, z.B. "E2E-TEST-1754130000000-3". */ function uniqueTag(suffix = '') { counter += 1 return `${TAG_PREFIX}-${Date.now()}-${counter}${suffix ? '-' + suffix : ''}` } /** * Minimales, gültiges PDF. Der Marker landet im Dateiinhalt, damit jeder * Upload einen eigenen SHA-256-Hash hat — sonst greift die Dublettenprüfung. */ function pdfBuffer(marker) { const pdf = [ '%PDF-1.4', '1 0 obj<>endobj', '2 0 obj<>endobj', '3 0 obj<>endobj', `% ${marker}`, 'trailer<>', '%%EOF', ].join('\n') return Buffer.from(pdf, 'latin1') } /** * Prüft, dass keine generische 500-Seite ausgeliefert wurde. * public/index.php zeigt bei unbehandelten Fehlern genau diesen Text — * ein reiner Statuscode-Check würde ihn nicht zuverlässig erkennen. */ async function expectNoServerError(page) { await expect(page.locator('body')).not.toContainText('Ein Fehler ist aufgetreten') } /** * Öffnet einen Pfad und stellt sicher, dass er ohne Serverfehler rendert. * * Wichtig: Eine abgelaufene Session leitet auf /login um, was mit Status 200 * endet. Ohne die URL-Prüfung liefe jeder Test danach gegen die Login-Seite und * schlüge mit einer irreführenden Meldung fehl. */ async function gotoOk(page, path) { const response = await page.goto(path) expect(response, `Keine Antwort für ${path}`).not.toBeNull() expect(response.status(), `Unerwarteter Status für ${path}`).toBeLessThan(400) expect( new URL(page.url()).pathname, `Statt ${path} kam die Login-Seite — die Session ist nicht (mehr) gültig.` ).not.toBe('/login') await expectNoServerError(page) return response } /** window.confirm() automatisch bestätigen (Löschdialoge, siehe app.js). */ function acceptDialogs(page) { page.on('dialog', dialog => dialog.accept().catch(() => {})) } /** * Lädt einen Beleg hoch und liefert dessen ID. * Nach dem Upload landet man auf der Detailseite /belege/{id}. */ async function uploadBeleg(page, { marker, belegtyp = 'Eingangsrechnung', datum = null, rechnungsNr = null } = {}) { const tag = marker || uniqueTag() await gotoOk(page, '/belege/upload') await page.setInputFiles('#beleg_file', { name: `${tag}.pdf`, mimeType: 'application/pdf', buffer: pdfBuffer(tag), }) await page.selectOption('#belegtyp', belegtyp) if (datum) await page.fill('#beleg_datum', datum) await page.fill('#rechnungs_nr', rechnungsNr || tag) await page.fill('#notes', tag) // Der Klick muss auf das Upload-Formular gescopet sein: In der Sidebar steht // das Logout-Formular (layout/nav.php) und dessen Submit-Button käme im DOM // zuerst — ein ungescopeter Treffer würde die Session beenden. await Promise.all([ page.waitForURL(/\/belege\/\d+/), page.click('form[action="/belege/upload"] button[type="submit"]'), ]) const id = belegIdFromUrl(page.url()) expect(id, `Beleg-ID nicht aus URL ermittelbar: ${page.url()}`).toBeGreaterThan(0) return { id, tag } } function belegIdFromUrl(url) { const m = url.match(/\/belege\/(\d+)/) return m ? Number(m[1]) : 0 } /** * Räumt einen Beleg ab: erst dessen Buchungen (nur im Testmodus löschbar), * dann den Beleg selbst. Fehler werden geschluckt — der Teardown darf einen * bereits gescheiterten Test nicht zusätzlich rot färben. */ async function cleanupBeleg(page, belegId) { if (!belegId) return acceptDialogs(page) try { await page.goto(`/belege/${belegId}`) // Buchungen des Belegs entfernen (Formulare mit action /bookings/{id}/delete). for (;;) { const form = page.locator('form[action^="/bookings/"][action$="/delete"]').first() if (await form.count() === 0) break await form.locator('button[type="submit"]').click() await page.waitForLoadState('networkidle') if (!page.url().includes(`/belege/${belegId}`)) { await page.goto(`/belege/${belegId}`) } } const delForm = page.locator(`form[action="/belege/${belegId}/delete"]`).first() if (await delForm.count() > 0) { await delForm.locator('button[type="submit"]').click() await page.waitForLoadState('networkidle') } } catch { // Aufräumen ist best effort. } } /** * Löscht eine Rechnung (nur im Testmodus erlaubt). * * Das Lösch-Formular steht in der Listenansicht (invoices/list.php), nicht in * der Detailansicht — der Aufräumschritt muss deshalb über /invoices gehen. */ async function cleanupInvoice(page, invoiceId) { if (!invoiceId) return acceptDialogs(page) try { await page.goto('/invoices') const form = page.locator(`form[action="/invoices/${invoiceId}/delete"]`).first() if (await form.count() > 0) { await form.locator('button[type="submit"]').click() await page.waitForLoadState('networkidle') } } catch { // Aufräumen ist best effort. } } /** Betrag "1.234,56 €" → 1234.56 */ function parseMoney(text) { if (!text) return NaN const m = String(text).match(/-?[\d.]+,\d{2}/) if (!m) return NaN return Number(m[0].replace(/\./g, '').replace(',', '.')) } module.exports = { TAG_PREFIX, uniqueTag, pdfBuffer, expectNoServerError, gotoOk, acceptDialogs, uploadBeleg, belegIdFromUrl, cleanupBeleg, cleanupInvoice, parseMoney, }