// @ts-check const { test, expect } = require('@playwright/test') const BENUTZER = process.env.E2E_USER || 'e2e' const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' async function anmelden(page) { await page.goto('/login') if (page.url().includes('/einrichtung')) { throw new Error( 'Instanz ist noch nicht eingerichtet. Zuerst einen Benutzer anlegen: ' + 'docker compose exec -u www-data php php scripts/e2e-benutzer.php' ) } await page.fill('#benutzername', BENUTZER) await page.fill('#passwort', PASSWORT) await page.click('button[type="submit"]') await expect(page).toHaveURL(/\/$/) } /** * Die Module aus den Phasen 4 bis 7: Cockpit-Kacheln, Online-Banking, * Wertpapiere und die Nebenmodule. * * Geprüft wird, dass jeder Bereich erreichbar ist und seine Kernansicht * aufbaut – nicht die Rechenlogik. Die steckt in der PHP-Suite, wo sie * schneller und isoliert läuft. */ test.describe('Module der Phasen 4 bis 7', () => { test.beforeEach(async ({ page }) => { await anmelden(page) }) test('Jeder Navigationspunkt führt in ein gebautes Modul', async ({ page }) => { const punkte = [ ['/konten', 'Kontenübersicht'], ['/banking', 'Bankverbindungen'], ['/zahlungsverkehr', 'Zahlungsverkehr'], ['/auswertungen', 'Auswertungen'], ['/planung', 'Planung'], ['/termine', 'Regelmäßige Buchungen'], ['/vertraege', 'Verträge'], ['/wertpapiere', 'Wertpapiere'], ['/verwaltung', 'Verwaltung'], ] // Jeder Bereich hat sein Modul: Die Überschrift gehört zum Modul, nicht // zur früheren Platzhalterseite. for (const [pfad, titel] of punkte) { await page.goto(pfad) await expect(page.locator('h1')).toContainText(titel) } }) test('Zahlenspalten stehen in allen Listen rechtsbündig', async ({ page }) => { // `.rechts` markiert Zahlen- und Aktionsspalten. Die Klasse muss die // linksbündige Grundregel der Listen überall schlagen – nicht nur in den // Berichten, wo es zuerst auffiel. for (const pfad of ['/konten', '/wertpapiere', '/wertpapiere/buchungen']) { await page.goto(pfad) for (const art of ['th', 'td']) { const zellen = page.locator(`table.liste ${art}.rechts`) const anzahl = await zellen.count() expect(anzahl, `${pfad}: ${art}.rechts vorhanden`).toBeGreaterThan(0) for (const stelle of [0, anzahl - 1]) { await expect(zellen.nth(stelle)).toHaveCSS('text-align', 'right') } } } }) test('Das Cockpit zeichnet seine Kacheln', async ({ page }) => { await page.goto('/') // Über die Überschriften, nicht über den Kacheltext: „Kontostände“ kommt // auch im Hinweis der Vermögenskachel vor. const ueberschriften = page.locator('.kachel h2') for (const titel of [ 'Vermögensentwicklung', 'Einnahmen und Ausgaben', 'Meine Ausgaben', 'Kontostände', ]) { await expect(ueberschriften.filter({ hasText: titel })).toHaveCount(1) } }) test('Legendeneinträge im Cockpit öffnen die Buchungen dahinter', async ({ page }) => { await page.goto('/') // Einnahmen und Ausgaben: der Legendeneintrag führt in die Buchungsliste. const monatskachel = page.locator('.kachel', { has: page.locator('h2', { hasText: 'Einnahmen und Ausgaben' }) }) await monatskachel.locator('a.legendenname', { hasText: 'Einnahmen' }).click() await expect(page).toHaveURL(/\/auswertungen\/buchungen\?.*art=einnahme/) await expect(page.locator('.berichtskopf h2')).toHaveText('Einnahmen') await expect(page.locator('table.liste tbody tr').first()).toBeVisible() // Der Weg zurück führt ins Cockpit, nicht in einen Bericht. await page.click('a.knopf:has-text("Zurück zum Cockpit")') await expect(page).toHaveURL(/\/$/) // Meine Ausgaben: jede Kategorie der Legende ist ein Link. const ausgabenkachel = page.locator('.kachel', { has: page.locator('h2', { hasText: 'Meine Ausgaben' }) }) const eintrag = ausgabenkachel.locator('.ringlegende a.legendenname').first() const name = (await eintrag.textContent() || '').trim() await eintrag.click() await expect(page).toHaveURL(/art=ausgabe/) await expect(page.locator('.berichtskopf h2')).toContainText(name.replace(/ \(\d+\)$/, '')) await expect(page.locator('tfoot .summenzeile')).toBeVisible() }) test('Abgewählte Kacheln verschwinden und kommen zurück', async ({ page }) => { await page.goto('/einstellungen?bereich=darstellung') const kachel = page.locator('input[name="cockpit_kacheln[]"][value="ausgaben"]') await kachel.uncheck() await page.click('button:has-text("Speichern")') await page.goto('/') await expect(page.locator('.kachel h2', { hasText: 'Meine Ausgaben' })).toHaveCount(0) await expect(page.locator('.kachelfuss')).toContainText('abgeschaltet') await page.goto('/einstellungen?bereich=darstellung') await page.locator('input[name="cockpit_kacheln[]"][value="ausgaben"]').check() await page.click('button:has-text("Speichern")') await page.goto('/') await expect(page.locator('.kachel h2', { hasText: 'Meine Ausgaben' })).toHaveCount(1) }) test('Eine Bankverbindung lässt sich anlegen und wieder löschen', async ({ page }) => { // Vor den drei Einrichtungsschritten steht die Wahl der Art; die // Bankenmaske hängt an der ersten der beiden Karten. await page.goto('/banking/neu') await page.click('.formatkarte:has-text("HBCI/FinTS")') await page.fill('#name', 'E2E-Testbank') await page.fill('#blz', '12030000') await page.fill('#fints_url', 'https://banking.example.org/fints') await page.fill('#benutzerkennung', 'e2e-kunde') await page.fill('#pin', 'geheim') // Schritt 1 speichert nur die Zugangsdaten. await expect(page.locator('.schrittmarke')).toContainText('Schritt 1 von 3') await page.click('button:has-text("Weiter")') // Schritt 2 fragt die Bank selbst nach ihren TAN-Verfahren. Die Adresse // im Test führt nirgendwohin – geprüft wird deshalb der Fehlerweg: Die // Seite erklärt, was zu tun ist, statt stehen zu bleiben. await expect(page.locator('.schrittmarke')).toContainText('Schritt 2 von 3') await expect(page.locator('h1')).toContainText('E2E-Testbank') await expect(page.locator('.meldung-fehler')).toBeVisible() await expect(page.locator('a:has-text("Zugangsdaten prüfen")')).toHaveCount(1) // Wer die Einrichtung vorzeitig verlässt, landet in der Übersicht: Die // Zugangsmaske böte nur noch an, was gerade durchlaufen wurde. await page.click('a:has-text("Später einstellen")') await expect(page.locator('h1')).toContainText('Bankverbindungen') await expect(page.locator('td a:has-text("E2E-Testbank")')).toHaveCount(1) await page.click('td a:has-text("E2E-Testbank")') await expect(page.locator('h1')).toContainText('E2E-Testbank') // Die PIN wird nie wieder angezeigt. await expect(page.locator('#pin')).toHaveValue('') await expect(page.locator('#pin')).toHaveAttribute('placeholder', /unverändert/) // Ohne Wahl bleibt der Hinweis stehen, und die Seite ist wieder erreichbar. await expect(page.locator('a:has-text("TAN-Verfahren einstellen")')).toHaveCount(1) // Der Stichtag gibt es zweimal: als Vorgabe des Zugangs … await page.selectOption('#abrufzeitraum', 'stichtag') await page.fill('#abruf_stichtag', '2026-01-01') await page.click('button:has-text("Speichern")') await expect(page.locator('.meldung')).toContainText('gespeichert') await expect(page.locator('#abruf_stichtag')).toHaveValue('2026-01-01') // … und als einmalige Angabe neben dem Abrufknopf, die nichts speichert. await expect(page.locator('.abrufform input[name="stichtag"]')).toHaveCount(1) await page.goto('/banking') await expect(page.locator('tr:has-text("E2E-Testbank")')).toContainText('Ab Stichtag') await expect(page.locator('tr:has-text("E2E-Testbank")')).toContainText('01.01.2026') await page.click('td a:has-text("E2E-Testbank")') // Schritt 3: die Kontenzuordnung. Ohne erreichbare Bank kennt sie noch // kein Konto und bietet die Abfrage an, statt eine leere Tabelle zu zeigen. await page.click('a:has-text("Konten zuordnen")') await expect(page.locator('h1')).toContainText('Konten zuordnen') await expect(page.locator('.platzhalter')).toContainText('Noch kein Konto bekannt') await expect(page.locator('button:has-text("Konten bei der Bank abfragen")')).toHaveCount(1) await page.click('a:has-text("Zurück zur Bankverbindung")') await expect(page.locator('h1')).toContainText('E2E-Testbank') page.once('dialog', dialog => dialog.accept()) await page.click('button:has-text("Bankverbindung löschen")') await expect(page.locator('.meldung')).toContainText('gelöscht') }) test('Eine falsche IBAN wird beim Anlegen einer Adresse abgewiesen', async ({ page }) => { await page.goto('/vertraege/adressen') await page.click('#neu-knopf') await page.fill('#name', 'E2E-Falschtest') await page.fill('#iban', 'DE02120300000000202052') await page.click('button:has-text("Adresse anlegen")') await expect(page.locator('.meldung-fehler')).toContainText('IBAN') }) test('Ein Kredit zeigt seinen gerechneten Tilgungsplan', async ({ page }) => { await page.goto('/vertraege/kredite') await page.click('#neu-knopf') await page.fill('#name', 'E2E-Darlehen') await page.fill('#darlehensbetrag', '100000') await page.fill('#zinssatz', '3,0') await page.fill('#rate', '500') await page.click('button:has-text("Kredit anlegen")') await expect(page.locator('h3', { hasText: 'Tilgungsplan' })).toContainText('E2E-Darlehen') await expect(page.locator('.kennzahltitel', { hasText: 'Zinsen gesamt' })).toHaveCount(1) // Erste Rate: 250 Zinsen, 250 Tilgung. Die erste Tabelle ist die // Kreditliste, der Plan steht in der zweiten. const ersteRate = page.locator('table.liste').last().locator('tbody tr').first() await expect(ersteRate).toContainText('250,00') page.once('dialog', dialog => dialog.accept()) await page.locator('tr', { hasText: 'E2E-Darlehen' }).locator('button:has-text("Löschen")').first().click() await expect(page.locator('.meldung')).toContainText('gelöscht') }) test('Depotbuchungen lassen sich über die Auswahl sammelweise löschen', async ({ page }) => { const marke = Date.now().toString().slice(-6) const depot = `E2E Depot ${marke}` const papier = `E2E Papier ${marke}` await page.goto('/konten/neu') await page.fill('#name', depot) await page.selectOption('#kontoart', 'depot') await page.click('.formular button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() // Zwei Käufe desselben Papiers – der zweite über die Auswahlliste, damit // kein zweites Wertpapier entsteht. for (const [nr, stueck] of [[1, '10'], [2, '5']]) { await page.goto('/wertpapiere/buchungen/neu') await page.selectOption('#konto_id', { label: depot }) await page.fill('#datum', '2026-05-0' + nr) if (nr === 1) { await page.fill('#wertpapier_name', papier) } else { await page.selectOption('#wertpapier_id', { label: papier }) } await page.fill('#stueck', stueck) await page.fill('#kurs', '100') await page.click('.formular button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() } // Der Filter bleibt über das Löschen hinweg stehen – das Formularziel // trägt ihn mit. await page.goto('/wertpapiere/buchungen') await page.selectOption('select[name="depot"]', { label: depot }) await page.click('button:has-text("Filtern")') await expect(page.locator('tbody tr')).toHaveCount(2) // Der Bestand kennt beide Käufe. await page.goto('/wertpapiere') await page.selectOption('select[name="depot"]', { label: depot }) await page.click('button:has-text("Anzeigen")') await expect(page.locator('tbody tr', { hasText: papier })).toContainText('15') await page.goBack() await page.goBack() // Ohne Auswahl bleibt der Knopf gesperrt. const knopf = page.locator('#depotbuchungsauswahl button') await expect(knopf).toBeDisabled() await page.check('.alle-waehlen') await expect(knopf).toContainText('(2)') const gefragt = [] page.on('dialog', async (dialog) => { gefragt.push(dialog.message()) await dialog.accept() }) await knopf.click() expect(gefragt.join(' ')).toContain('löschen') await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht') await expect(page).toHaveURL(/depot=/) // Der Name steht weiter im Filterfeld – gemeint ist die Liste. await expect(page.locator('table.liste')).toHaveCount(0) await expect(page.locator('.platzhalter')).toContainText('Keine Depotbuchungen') // Und der Bestand ist die Buchungen los – er wird gerechnet. await page.goto('/wertpapiere') await page.selectOption('select[name="depot"]', { label: depot }) await page.click('button:has-text("Anzeigen")') await expect(page.locator('.platzhalter')).toContainText('Keine Bestände') // Aufräumen: Das Testdepot wieder entfernen. Die Rückfrage übernimmt der // Dialoghandler von oben. await page.goto('/konten') const pfad = await page.locator('a', { hasText: depot }).first().getAttribute('href') await page.goto(pfad + '/bearbeiten') await page.locator('button:has-text("Konto löschen")').click() await expect(page.locator('.meldung')).toContainText('gelöscht') }) test('Regelmäßige Buchungen werden per Klick bearbeitbar', async ({ page }) => { await page.goto('/termine') await page.click('#neu-knopf') await page.fill('#name', 'E2E-Reihe') await page.fill('#neu_betrag', '-12,50') await page.fill('#neu_buchungstext', 'E2E-Text') await expect(page.locator('#neudialog input[name="abgleichen"]')).toBeChecked() await page.click('form[action="/termine/neu"] button.primaer') const zeile = page.locator('table.reihen tr', { hasText: 'E2E-Reihe' }) await expect(page.locator('table.reihen thead th').nth(7)).toHaveText('Abgleichen') const abgleichen = zeile.locator('.feld:has(input[name="abgleichen"])') await expect(abgleichen.locator('.anzeige')).toHaveText('Ja') await abgleichen.locator('.anzeige').click() await expect(abgleichen.locator('.anzeige')).toHaveText('Nein') await zeile.locator('[data-speichern]').click() await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' }) .locator('.feld:has(input[name="abgleichen"]) .anzeige')).toHaveText('Nein') await expect(zeile.locator('input[name="betrag"]')).toBeHidden() await expect(zeile.locator('[data-speichern]')).toBeHidden() await zeile.locator('.feld:has(input[name="betrag"]) .anzeige').click() await expect(zeile.locator('input[name="betrag"]')).toBeVisible() await zeile.locator('input[name="betrag"]').fill('-13,00') await expect(zeile.locator('[data-speichern]')).toBeVisible() await zeile.locator('[data-speichern]').click() await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })).toContainText('13,00') page.once('dialog', (dialog) => dialog.accept()) await page.locator('table.reihen tr', { hasText: 'E2E-Reihe' }).locator('button:has-text("Löschen")').click() await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })).toHaveCount(0) }) test('Fälliges Datum lässt sich vor dem Buchen per Klick ändern', async ({ page }) => { await page.goto('/termine') await page.click('#neu-knopf') await page.fill('#name', 'E2E-Fällig') await page.fill('#neu_betrag', '-1,00') await page.click('form[action="/termine/neu"] button.primaer') const zeile = page.locator('table.faellig tr', { hasText: 'E2E-Fällig' }) const datum = zeile.locator('input[name="datum"]') await expect(datum).toBeHidden() await zeile.locator('.feld[data-datum] .anzeige').click() await expect(datum).toBeVisible() await expect(zeile.locator('button:has-text("Buchen")')).toBeVisible() page.once('dialog', (dialog) => dialog.accept()) await page.locator('table.reihen tr', { hasText: 'E2E-Fällig' }).locator('button:has-text("Löschen")').click() await expect(page.locator('table.reihen tr', { hasText: 'E2E-Fällig' })).toHaveCount(0) }) test('Der Finanzkalender ist erreichbar und lässt sich einstellen', async ({ page }) => { await page.goto('/termine/kalender') await expect(page.locator('h1')).toContainText('Finanzkalender') await page.selectOption('#tage', '90') await page.click('button:has-text("Anzeigen")') await expect(page).toHaveURL(/tage=90/) }) test('Der Zahlungsverkehr weist eine falsche IBAN ab', async ({ page }) => { await page.goto('/zahlungsverkehr/neu') await page.fill('#empfaenger', 'E2E-Empfänger') await page.fill('#iban', 'DE00000000000000000000') await page.fill('#betrag', '10,00') await page.click('button:has-text("Erfassen")') // Die Bankverbindung ist bewusst nicht Pflicht: Ein Auftrag darf erfasst // werden, bevor eine eingerichtet ist. Geprüft wird sie erst beim Senden. await expect(page.locator('.meldung-fehler')).toContainText('IBAN') }) test('Die Echtzeitüberweisung sperrt den Ausführungstermin', async ({ page }) => { await page.goto('/zahlungsverkehr/neu') await page.fill('#ausfuehrung_am', '2099-09-05') await page.check('#echtzeit') // Eine Echtzeitüberweisung geht sofort hinaus – der Termin wird geleert // und gesperrt, solange sie gewählt ist. await expect(page.locator('#ausfuehrung_am')).toBeDisabled() await expect(page.locator('#ausfuehrung_am')).toHaveValue('') await page.uncheck('#echtzeit') await expect(page.locator('#ausfuehrung_am')).toBeEnabled() }) test('Eine Überweisung übernimmt den Empfänger auf Wunsch ins Adressbuch', async ({ page }) => { const empfaenger = `E2E-Adressbuch ${Date.now()}` await page.goto('/zahlungsverkehr/neu') await page.fill('#empfaenger', empfaenger) await page.fill('#iban', 'DE02120300000000202051') await page.fill('#betrag', '12,34') await page.check('input[name="adresse_merken"]') await page.click('button:has-text("Erfassen")') await expect(page.locator('.meldung-erfolg')).toContainText('erfasst') // Die Adresse landet wirklich im Adressbuch – nicht nur der Auftrag. // Die Liste ist eine Bearbeitungstabelle: Die Namen stehen in // Eingabefeldern, nicht als Text. await page.goto('/vertraege/adressen') await expect(page.locator(`table.liste input[name="name"][value="${empfaenger}"]`)) .toHaveCount(1) }) test('Die Empfängerauswahl im Zahlungsverkehr ist ein Typeahead aus dem Adressbuch', async ({ page }) => { const empfaenger = `E2E-Typeahead ${Date.now()}` // Erst eine Adresse anlegen, damit die Auswahl etwas zu bieten hat. await page.goto('/vertraege/adressen') await page.click('#neu-knopf') await page.fill('#name', empfaenger) await page.fill('#iban', 'DE02120300000000202051') await page.click('button:has-text("Adresse anlegen")') await expect(page.locator('.meldung-erfolg')).toContainText('angelegt') await page.goto('/zahlungsverkehr/neu') // kombobox.js ersetzt das