// @ts-check const { test, expect } = require('@playwright/test') const BENUTZER = process.env.E2E_USER || 'e2e' const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' // Eindeutige Namen je Lauf: die Tests arbeiten auf einer bestehenden Instanz // und dürfen sich nicht an Resten eines früheren Laufs stoßen. const MARKE = Date.now().toString().slice(-6) 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(/\/$/) } test.describe('Stammdaten', () => { test.beforeEach(async ({ page }) => { await anmelden(page) }) test('Kontenübersicht ist erreichbar', async ({ page }) => { await page.click('.seitenleiste a[href="/konten"]') await expect(page.locator('h1')).toContainText('Kontenübersicht') await expect(page.locator('a[href="/konten/neu"]').first()).toBeVisible() }) test('Konto anlegen und in der Übersicht wiederfinden', async ({ page }) => { const name = `E2E Girokonto ${MARKE}` await page.goto('/konten/neu') await page.fill('#name', name) await page.selectOption('#kontoart', 'bank') await page.fill('#anfangssaldo', '1.234,56') await page.click('.formular button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() await page.goto('/konten') const zeile = page.locator('tr', { hasText: name }) await expect(zeile).toContainText('Bank') // Deutsches Betragsformat, geschütztes Leerzeichen vor dem Euro-Zeichen. await expect(zeile).toContainText('1.234,56') await expect(page.locator('.summenzeile')).toContainText('Summe Kontostände') }) // Ein nicht angehaktes Ankreuzfeld schickt der Browser gar nicht mit. Bleibt // es serverseitig unbeachtet, meldet die Anwendung „Konto gespeichert“ und // das Kennzeichen steht unverändert da. test('Σ-Kennzeichen lässt sich abschalten und wieder setzen', async ({ page }) => { const name = `E2E Auslagen ${MARKE}` await page.goto('/konten/neu') await page.fill('#name', name) await page.selectOption('#kontoart', 'sonstiges') await page.fill('#anfangssaldo', '500,00') await page.click('.formular button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() const kennzeichen = page.locator('input[name="in_summe"]') await expect(kennzeichen).toBeChecked() await kennzeichen.uncheck() await page.locator('.formular button[type="submit"]').first().click() await expect(page.locator('.meldung-erfolg')).toBeVisible() // In der Übersicht bleibt das Konto sichtbar, aber als „ohne Σ“ markiert. const zeile = page.locator('tr', { hasText: name }) await expect(zeile).toContainText('ohne Σ') // Und im Formular ist das Häkchen tatsächlich weg – nicht nur die Meldung da. await zeile.locator('a').first().click() await page.click('a.tab[href$="/bearbeiten"]') await expect(page.locator('input[name="in_summe"]')).not.toBeChecked() await page.locator('input[name="in_summe"]').check() await page.locator('.formular button[type="submit"]').first().click() await expect(page.locator('tr', { hasText: name })).not.toContainText('ohne Σ') }) // Das Kontoformular steht zweispaltig, damit alle Felder ohne vertikales // Scrollen sichtbar sind. Geprüft wird an den Kästen: Kontoart steht rechts // neben dem Namen, und das Formular endet oberhalb der Fensterhöhe. test('Kontoformular steht zweispaltig und ohne Scrollen', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }) await page.goto('/konten/neu') const name = await page.locator('#name').boundingBox() const kontoart = await page.locator('#kontoart').boundingBox() // Gleiche Zeile, zweite Spalte. expect(Math.abs(name.y - kontoart.y)).toBeLessThan(5) expect(kontoart.x).toBeGreaterThan(name.x + name.width) const knopf = await page.locator('.formular button[type="submit"]').first().boundingBox() expect(knopf.y + knopf.height).toBeLessThan(900) }) test('Kategorie mit Unterkategorie anlegen', async ({ page }) => { const oben = `E2E Wohnen ${MARKE}` const unten = `Strom ${MARKE}` await page.goto('/verwaltung/kategorien/neu') await page.fill('#name', oben) await page.selectOption('#art', 'ausgabe') await page.click('.formular button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() // Nach dem Anlegen steht man im Bearbeitungsformular der Kategorie. await expect(page.locator('.feldwert code')).toContainText(oben) await page.goto('/verwaltung/kategorien/neu') await page.fill('#name', unten) await page.selectOption('#eltern_id', { label: oben }) await page.click('.formular button[type="submit"]') await expect(page.locator('.feldwert code')).toContainText(`${oben}:${unten}`) await page.goto('/verwaltung/kategorien') await expect(page.locator('tr', { hasText: oben }).first()).toContainText('Oberkategorie') // Die Unterkategorie ist um eine Ebene eingerückt. Die Ebene steht als // data-Attribut da: Ein style-Attribut würde die Content-Security-Policy // verwerfen, die Einrückung fiele stillschweigend aus. await expect(page.locator(`tr:has-text("${unten}") .baumname`)) .toHaveAttribute('data-ebene', '1') }) test('Register filtern nach Einnahmen und Ausgaben', async ({ page }) => { const einnahme = `E2E Gehalt ${MARKE}` await page.goto('/verwaltung/kategorien/neu') await page.fill('#name', einnahme) await page.selectOption('#art', 'einnahme') await page.click('.formular button[type="submit"]') await page.goto('/verwaltung/kategorien?register=einnahmen') await expect(page.locator('.tab.aktiv')).toContainText('Einnahmen') await expect(page.locator('tbody')).toContainText(einnahme) await page.goto('/verwaltung/kategorien?register=ausgaben') await expect(page.locator('tbody')).not.toContainText(einnahme) }) test('Umbuchungskonten zeigen die Konten in eckigen Klammern', async ({ page }) => { await page.goto('/verwaltung/kategorien?register=umbuchungskonten') await expect(page.locator('.tab.aktiv')).toContainText('Umbuchungskonten') await expect(page.locator('tbody a').first()).toContainText(/^\[.+\]$/) }) test('Klasse anlegen und wieder löschen', async ({ page }) => { const name = `E2E Projekt ${MARKE}` await page.goto('/verwaltung/klassen') await page.fill('.formular #name', name) await page.fill('.formular #beschreibung', 'aus dem E2E-Test') await page.click('.formular button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() // Die Liste ist bearbeitbar: Name und Beschreibung stehen in // Eingabefeldern, nicht als Text – deshalb über den Attributwert suchen. const zeile = page.locator('tr').filter({ has: page.locator(`input[name="name"][value="${name}"]`), }) await expect(zeile).toHaveCount(1) await expect(zeile.locator('input[name="beschreibung"]')).toHaveValue('aus dem E2E-Test') // Den Dialog sofort schließen: solange er offen ist, kehrt click() nicht // zurück. Die Bestätigung kommt aus /static/app.js (data-bestaetigen). page.on('dialog', async (dialog) => await dialog.accept()) await zeile.getByRole('button', { name: 'Löschen' }).click() await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht') }) test('QIF-Trockenlauf zeigt an, was ankäme, ohne zu speichern', async ({ page }) => { const dateiname = `e2e-probe-${MARKE}.qif` const klasse = `E2E Klasse ${MARKE}` const inhalt = [ '!Option:MDY', '!Type:Class', `N${klasse}`, '^', '!Type:Cat', `NE2E Kategorie ${MARKE}`, 'E', '^', '!Option:AutoSwitch', '!Account', `NE2E Konto ${MARKE}`, 'TBank', '^', '!Clear:AutoSwitch', '', ].join('\r\n') await page.goto('/verwaltung/import?format=qif') await page.setInputFiles('#datei', { name: dateiname, mimeType: 'application/octet-stream', buffer: Buffer.from(inhalt, 'latin1'), }) await page.click('.formular button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toContainText('hochgeladen') const zeile = page.locator('tr', { hasText: dateiname }) await zeile.getByRole('button', { name: 'Trockenlauf' }).click() await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf') const uebersicht = page.locator('.kachel table.liste').first() await expect(uebersicht.locator('tr', { hasText: 'Klassen' })).toContainText('1') // Genau die Zeile „Konten“ – „Kontengruppen“ steht direkt darunter und // enthält denselben Wortanfang, deshalb über die erste Zelle filtern. const kontenzeile = uebersicht.locator('tr').filter({ has: page.getByRole('cell', { name: 'Konten', exact: true }), }) await expect(kontenzeile).toContainText('1') // Nichts gespeichert: die Klasse darf noch nicht in der Liste stehen. await page.goto('/verwaltung/klassen') await expect(page.locator('body')).not.toContainText(klasse) // Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen. await page.goto('/verwaltung/import?format=qif') page.on('dialog', async (dialog) => await dialog.accept()) await page.locator('tr', { hasText: dateiname }) .getByRole('button', { name: 'Löschen' }).click() await expect(page.locator('body')).not.toContainText(dateiname) }) test('Kontoauswahl steht erst nach dem Trockenlauf und grenzt den Import ein', async ({ page }) => { const dateiname = `e2e-konten-${MARKE}.qif` const giro = `E2E Giro ${MARKE}` const tagesgeld = `E2E Tagesgeld ${MARKE}` const inhalt = [ '!Option:MDY', '!Option:AutoSwitch', '!Account', `N${giro}`, 'TBank', '^', `N${tagesgeld}`, 'TOth T', '^', '!Clear:AutoSwitch', '!Account', `N${giro}`, 'TBank', '^', '!Type:Bank', 'D4.1.26', 'T-250.00', 'PÜbertrag', `L[${tagesgeld}]`, '^', 'D5.2.26', 'T-49.90', 'PSupermarkt', '^', '', ].join('\r\n') await page.goto('/verwaltung/import?format=qif') await page.setInputFiles('#datei', { name: dateiname, mimeType: 'application/octet-stream', buffer: Buffer.from(inhalt, 'latin1'), }) await page.click('.formular button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toContainText('hochgeladen') // Vor dem ersten Lesen der Datei gibt es nichts zu wählen. await expect(page.locator('input[name="konten[]"]')).toHaveCount(0) await page.locator('tr', { hasText: dateiname }) .getByRole('button', { name: 'Trockenlauf' }).click() // Danach stehen genau die Konten der Datei zur Wahl – beide angehakt. const kaestchen = page.locator('input[name="konten[]"]') await expect(kaestchen).toHaveCount(2) await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).toBeChecked() await expect(page.locator(`input[name="konten[]"][value="${tagesgeld}"]`)).toBeChecked() // Nur das Tagesgeld übernehmen: Die Umbuchung zeigt hierher und bleibt, // die reine Girobuchung fällt heraus. await page.locator(`input[name="konten[]"][value="${giro}"]`).uncheck() await page.getByRole('button', { name: 'Trockenlauf mit dieser Auswahl' }).click() await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf') await expect(page.locator('tr', { hasText: 'gehört zu keinem der gewählten Konten' })) .toContainText('1') await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked() // Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen. await page.goto('/verwaltung/import?format=qif') page.on('dialog', async (dialog) => await dialog.accept()) await page.locator('tr', { hasText: dateiname }) .getByRole('button', { name: 'Löschen' }).click() await expect(page.locator('body')).not.toContainText(dateiname) }) })