// @ts-check const { test, expect } = require('@playwright/test') const BENUTZER = process.env.E2E_USER || 'e2e' const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' const MARKE = Date.now().toString().slice(-6) const GIRO = `E2E Giro ${MARKE}` const SPAR = `E2E Spar ${MARKE}` const KATEGORIE = `E2E Einkauf ${MARKE}` const KLASSE = `E2E Privat ${MARKE}` async function anmelden(page) { await page.goto('/login') if (page.url().includes('/einrichtung')) { throw new Error( 'Instanz ist noch nicht eingerichtet: ' + '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(/\/$/) } /** Legt ein Konto an und liefert dessen Kennung aus der URL. */ async function kontoAnlegen(page, name, anfangssaldo) { await page.goto('/konten/neu') await page.fill('#name', name) await page.fill('#anfangssaldo', anfangssaldo) await page.click('.formular button[type="submit"]') await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/) const treffer = page.url().match(/\/konten\/(\d+)\//) return treffer ? treffer[1] : null } test.describe('Buchungen', () => { let giroId = null let sparId = null test.beforeAll(async ({ browser }) => { const page = await browser.newPage() await anmelden(page) giroId = await kontoAnlegen(page, GIRO, '1.000,00') sparId = await kontoAnlegen(page, SPAR, '5.000,00') await page.goto('/verwaltung/kategorien/neu') await page.fill('#name', KATEGORIE) await page.selectOption('#art', 'ausgabe') await page.click('.formular button[type="submit"]') await page.goto('/verwaltung/klassen') await page.fill('form[action="/verwaltung/klassen/neu"] #name', KLASSE) await page.click('form[action="/verwaltung/klassen/neu"] button[type="submit"]') await page.close() }) test.beforeEach(async ({ page }) => { await anmelden(page) }) test('Kontoblatt zeigt den Anfangssaldo', async ({ page }) => { await page.goto(`/konten/${giroId}`) await expect(page.locator('h1')).toContainText(GIRO) await expect(page.locator('.saldoanzeige').first()).toContainText('1.000,00') }) // Die Maske nennt oben das Konto, in dem die Buchung liegt, und stellt ihre // Felder zweispaltig – beides, damit man beim Erfassen weder rätselt noch // vertikal scrollt. test('Buchungsmaske nennt das Konto und passt auf einen Bildschirm', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }) await page.goto(`/konten/${giroId}/buchung`) // Das Konto steht in der Maske selbst, nicht nur in der Kopfzeile. await expect(page.locator('.buchungsmaske .feldwert')).toContainText(GIRO) // Gleiche Zeile, zweite Spalte. const art = await page.locator('#art').boundingBox() const datum = await page.locator('#datum').boundingBox() expect(Math.abs(art.y - datum.y)).toBeLessThan(5) expect(datum.x).toBeGreaterThan(art.x + art.width) // Und die Beschriftung steht links neben der Eingabe, nicht darüber. const beschriftung = await page.locator('label[for="betrag"]').boundingBox() const betrag = await page.locator('#betrag').boundingBox() expect(beschriftung.x + beschriftung.width).toBeLessThanOrEqual(betrag.x) expect(Math.abs( (beschriftung.y + beschriftung.height / 2) - (betrag.y + betrag.height / 2) )).toBeLessThan(5) // Die Erklärungen stehen im Tooltip der Beschriftung, nicht als eigene // Zeile darunter – sonst passte die Maske nicht auf einen Bildschirm. await expect(page.locator('label[for="zuordnung"]')).toHaveAttribute('title', /Umbuchung/) await expect(page.locator('label[for="betrag"]')).toHaveAttribute('title', /positiv/) // Und genau das ist der Zweck: Die ganze Maske samt Splitzeilen und // Knopfleiste endet oberhalb der Fensterhöhe. const maske = await page.locator('.buchungsmaske').boundingBox() expect(maske.y + maske.height).toBeLessThan(900) }) test('Ausgabe buchen und Saldo fortschreiben', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) await page.selectOption('#art', 'ausgabe') await page.fill('#datum', '2026-03-18') await page.fill('#betrag', '49,90') await page.fill('#empfaenger', 'Supermarkt') await page.selectOption('#zuordnung', { label: KATEGORIE }) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() const zeile = page.locator('tr', { hasText: 'Supermarkt' }) await expect(zeile).toContainText('-49,90') await expect(zeile).toContainText('950,10') await expect(page.locator('.saldoanzeige').first()).toContainText('950,10') }) test('Umbuchung erscheint auf beiden Kontoblättern', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) // Die Umbuchung entsteht im Kategoriefeld: Unter den Kategorien stehen // die eigenen Konten in eckigen Klammern. await page.selectOption('#art', 'ausgabe') await page.fill('#datum', '2026-04-01') await page.fill('#betrag', '500') await page.fill('#empfaenger', `Übertrag ${MARKE}`) await page.selectOption('#zuordnung', { label: `[${SPAR}]` }) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() // Ausgangskonto: Abgang, mit dem Gegenkonto in eckigen Klammern. const hin = page.locator('tr', { hasText: `Übertrag ${MARKE}` }) await expect(hin).toContainText('-500,00') await expect(hin).toContainText(`[${SPAR}]`) // Gegenkonto: derselbe Vorgang mit umgekehrtem Vorzeichen – und mit dem // Konto, von dem er kommt. Ohne diese Angabe stünde dort eine Umbuchung // ohne erkennbares Gegenüber. await page.goto(`/konten/${sparId}`) const her = page.locator('tr', { hasText: `Übertrag ${MARKE}` }) await expect(her).toContainText('500,00') await expect(her).toContainText('Umbuchung') await expect(her).toContainText(`[${GIRO}]`) await expect(page.locator('.saldoanzeige').first()).toContainText('5.500,00') // Auch die Gegenseite ist anklickbar: Sie führt zu ihrer Buchung, die // auf dem anderen Konto liegt und nur dort geändert wird. await her.locator('a.zuordnungsfeld.gegenseite').click() await expect(page).toHaveURL(/\/buchungen\/\d+/) await expect(page.locator('h1')).toContainText('Buchung bearbeiten') await expect(page.locator('#zuordnung')).toHaveValue(/^konto:\d+$/) }) test('Kategorie im Kontoblatt zuordnen', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) await page.fill('#datum', '2026-04-03') await page.fill('#betrag', '12,30') await page.fill('#empfaenger', `Ohne Kategorie ${MARKE}`) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() const zeile = page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` }) await zeile.locator('a.zuordnungsfeld').click() // Die Auswahlliste gibt es einmal und wandert in die angeklickte Zelle. const liste = zeile.locator('#zuordnungsauswahl select') await expect(liste).toBeVisible() await liste.selectOption({ label: KATEGORIE }) await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung') await expect(page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` })) .toContainText(KATEGORIE) }) // Fehler #40: Das Speichern lud bisher die ganze Seite neu und riss den // Bildschirm nach oben – bei einer Buchung weiter unten in der Liste ein // langer Weg zurück. Die Markierung übersteht nur, wenn nichts neu lädt. test('Kategorie ändern lädt die Seite nicht neu und lässt die Bildlaufposition stehen', async ({ page }) => { // Ein niedriges Fenster erzwingt Bildlauf, unabhängig davon, wie viele // Buchungen die vorigen Tests schon angelegt haben. await page.setViewportSize({ width: 1280, height: 400 }) await page.goto(`/konten/${giroId}/buchung`) await page.fill('#datum', '2026-04-05') await page.fill('#betrag', '5,55') await page.fill('#empfaenger', `Kein Neuladen ${MARKE}`) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() await page.goto(`/konten/${giroId}`) await page.evaluate(() => { window.scrollTo(0, 300) }) await page.evaluate(() => { window.__e2eKeinNeuladen = true }) const zeile = page.locator('tr', { hasText: `Kein Neuladen ${MARKE}` }) await zeile.locator('a.zuordnungsfeld').click() await zeile.locator('#zuordnungsauswahl select').selectOption({ label: KATEGORIE }) await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung') await expect(zeile).toContainText(KATEGORIE) // Ein echtes Neuladen hätte sowohl die Markierung gelöscht als auch die // Bildlaufposition auf null zurückgesetzt. expect(await page.evaluate(() => window.__e2eKeinNeuladen)).toBe(true) expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0) }) test('Klasse im Kontoblatt zuordnen und wieder löschen', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) await page.fill('#datum', '2026-04-06') await page.fill('#betrag', '8,80') await page.fill('#empfaenger', `Ohne Klasse ${MARKE}`) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() // Dieselbe Bauart wie bei der Zuordnung: die Liste wandert in die Zelle. const zeile = page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` }) await zeile.locator('a.klassenfeld').click() const liste = zeile.locator('#klassenauswahl select') await expect(liste).toBeVisible() await liste.selectOption({ label: KLASSE }) await expect(page.locator('.meldung-erfolg')).toContainText('Klasse gespeichert') await expect(page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` })) .toContainText(KLASSE) // Der erste Eintrag der Liste löscht die vorhandene Klasse wieder. const danach = page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` }) await danach.locator('a.klassenfeld').click() await danach.locator('#klassenauswahl select').selectOption('') await expect(page.locator('.meldung-erfolg')).toContainText('Klasse gelöscht') await expect(page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` })) .not.toContainText(KLASSE) }) test('Umbuchung im Kontoblatt zuordnen', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) await page.fill('#datum', '2026-04-04') await page.fill('#betrag', '75') await page.fill('#empfaenger', `Nachträglich ${MARKE}`) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() const zeile = page.locator('tr', { hasText: `Nachträglich ${MARKE}` }) await zeile.locator('a.zuordnungsfeld').click() await zeile.locator('#zuordnungsauswahl select').selectOption({ label: `[${SPAR}]` }) await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung') await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` })) .toContainText(`[${SPAR}]`) // Und die Gegenseite steht sofort auf dem anderen Konto. await page.goto(`/konten/${sparId}`) await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` })) .toContainText(`[${GIRO}]`) }) test('Splitbuchung aus dem Kontoblatt heraus aufteilen', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) await page.fill('#datum', '2026-04-05') await page.fill('#betrag', '90') await page.fill('#empfaenger', `Aufzuteilen ${MARKE}`) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() const zeile = page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }) await zeile.locator('a.zuordnungsfeld').click() await zeile.locator('#zuordnungsauswahl select').selectOption('split') const dialog = page.locator('#splitdialog') await expect(dialog).toBeVisible() await expect(dialog).toContainText('-90,00') await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE }) await dialog.locator('input[name="split[0][betrag]"]').fill('-50') await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` }) await dialog.locator('input[name="split[1][betrag]"]').fill('-40') // Der Dialog rechnet den Restbetrag mit; er muss aufgehen. await expect(page.locator('#splitdialogsumme')).not.toContainText('Rest') // Der Dialog hat zwei Submit-Knöpfe: Speichern und Löschen. await dialog.locator('button[type="submit"].primaer').click() await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung') await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first()) .toContainText('Splitbuchung') // Die Zeile mit Gegenkonto erscheint anteilig auf dem anderen Konto. await page.goto(`/konten/${sparId}`) await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` })).toContainText('40,00') }) test('Splitbuchung öffnet den Dialog und lässt sich dort löschen', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) await page.fill('#datum', '2026-04-06') await page.fill('#betrag', '70') await page.fill('#empfaenger', `Wieder einfach ${MARKE}`) await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE }) await page.fill('input[name="split[0][betrag]"]', '-30') await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE }) await page.fill('input[name="split[1][betrag]"]', '-40') await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() // Der Klick führt nicht in die Auswahlliste, sondern in den Dialog – // mit den vorhandenen Zeilen darin. const zeile = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` }) await zeile.locator('a.zuordnungsfeld').click() const dialog = page.locator('#splitdialog') await expect(dialog).toBeVisible() await expect(page.locator('#zuordnungsauswahl select')).toBeHidden() await expect(dialog.locator('input[name="split[0][betrag]"]')).toHaveValue('-30,00') await dialog.locator('#splitloeschen').click() await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung gelöscht') const danach = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` }) await expect(danach).not.toContainText('Splitbuchung') await expect(danach).not.toContainText(KATEGORIE) // Und die Zuordnung geht jetzt wieder über die Auswahlliste. await danach.locator('a.zuordnungsfeld').click() await expect(danach.locator('#zuordnungsauswahl select')).toBeVisible() }) test('Splitbuchung mit zwei Zeilen', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) await page.fill('#datum', '2026-05-02') await page.fill('#betrag', '260,49') await page.fill('#empfaenger', `Versicherung ${MARKE}`) await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE }) await page.fill('input[name="split[0][notiz]"]', 'Kasko') await page.fill('input[name="split[0][betrag]"]', '-162,51') await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE }) await page.fill('input[name="split[1][notiz]"]', 'Haftpflicht') await page.fill('input[name="split[1][betrag]"]', '-97,98') // Die Maske rechnet den Restbetrag mit; er muss aufgehen. await expect(page.locator('#splitsumme')).not.toContainText('Rest') await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() const zeile = page.locator('tr', { hasText: `Versicherung ${MARKE}` }) await expect(zeile).toContainText('Splitbuchung') await expect(zeile).toContainText('-260,49') // In der Spalte steht nur „Splitbuchung“ – wohin das Geld geht, zeigt // der Tooltip: je Zeile Kategorie und Betrag. const titel = await zeile.locator('a.zuordnungsfeld').getAttribute('title') expect(titel).toContain(`${KATEGORIE}: -162,51`) expect(titel).toContain(`${KATEGORIE}: -97,98`) }) test('Buchungszeilen sind gleich hoch, abgeschnittene Texte stehen im Tooltip', async ({ page }) => { const LANG = `Verwendungszweck ${MARKE} mit sehr vielen Wörtern, `.repeat(6).trim() await page.goto(`/konten/${giroId}/buchung`) await page.fill('#datum', '2026-05-04') await page.fill('#betrag', '11,11') await page.fill('#empfaenger', `Langtext ${MARKE}`) await page.fill('#buchungstext', LANG) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() // Einheitlich zwei Textzeilen hoch: Eine Zeile, die mit ihrem Text // wächst, ließe das Kontoblatt beim Blättern hüpfen. const hoehen = await page.locator('table.kontoblatt tbody tr').evaluateAll( (zeilen) => zeilen.map((zeile) => Math.round(zeile.getBoundingClientRect().height)) ) expect(hoehen.length).toBeGreaterThan(1) // Ein Pixel Toleranz: Die letzte Zeile hat keine Trennlinie. expect(Math.max(...hoehen) - Math.min(...hoehen)).toBeLessThanOrEqual(1) const zeile = page.locator('tr', { hasText: `Langtext ${MARKE}` }) // Der abgeschnittene Buchungstext steht vollständig im Tooltip … await expect(zeile.locator('td.buchungstext .zellentext')) .toHaveAttribute('title', LANG) // … der kurze Empfänger daneben bekommt keinen: Dort fehlt nichts. await expect(zeile.locator('td').nth(3).locator('.zellentext')) .not.toHaveAttribute('title') }) test('Splitzeilen, die nicht aufgehen, werden abgewiesen', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) await page.fill('#datum', '2026-05-03') await page.fill('#betrag', '100') await page.fill('#empfaenger', `Krumm ${MARKE}`) await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE }) await page.fill('input[name="split[0][betrag]"]', '-60') await expect(page.locator('#splitsumme')).toContainText('Rest') await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-fehler')).toBeVisible() await expect(page.locator('.meldung-fehler')).toContainText('Splitzeilen') }) test('Kontoblatt lässt sich durchsuchen', async ({ page }) => { await page.goto(`/konten/${giroId}`) await page.fill('input[name="suche"]', 'Supermarkt') await page.click('.filterleiste button[type="submit"]') // Auf der Seite steht noch eine zweite Tabelle: die leeren Zeilen des // Splitdialogs. Gemeint ist hier das Kontoblatt. await expect(page.locator('table.kontoblatt tbody tr')).toHaveCount(1) await expect(page.locator('table.kontoblatt tbody')).toContainText('Supermarkt') // Mit Filter ist die Saldospalte leer – ein laufender Saldo wäre gelogen. // Spalte 0 ist das Ankreuzfeld der Mehrfachauswahl. await expect(page.locator('table.kontoblatt tbody tr td').nth(8)).toContainText('–') }) test('Eine Buchung ohne Kategorie setzt ihr Konto in der Übersicht fett', async ({ page }) => { // Heutiges Datum: Fett stehen nur Konten mit offenen Buchungen aus dem // laufenden Jahr – ein festes Datum wäre nächstes Silvester falsch. const heute = new Date().toISOString().slice(0, 10) await page.goto(`/konten/${sparId}/buchung`) await page.selectOption('#art', 'ausgabe') await page.fill('#datum', heute) await page.fill('#betrag', '17,50') await page.fill('#empfaenger', 'Noch zuzuordnen') // Kategorie bleibt „— keine —“: genau der Zustand, den ein automatisch // gebuchter Bankumsatz ohne passende Regel hinterlässt. await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() await page.goto('/konten') await expect(page.locator('tr.unkategorisiert', { hasText: SPAR })).toHaveCount(1) await expect(page.locator('tr.unkategorisiert a', { hasText: SPAR })) .toHaveAttribute('title', /ohne Kategorie/) }) test('Löschen fragt nach – trotz strenger Content-Security-Policy', async ({ page }) => { await page.goto(`/konten/${giroId}`) await page.locator('tr', { hasText: 'Supermarkt' }).getByRole('link').first().click() await expect(page).toHaveURL(/\/buchungen\/\d+/) // Der Dialog MUSS kommen: die Bestätigung hängt an data-bestaetigen und // wird von /static/app.js gesetzt, weil Inline-Handler an der CSP // scheitern. Der Handler muss vor dem Klick stehen und den Dialog // sofort schließen – ein offener Dialog blockiert die Klickaktion. const gefragt = [] page.on('dialog', async (dialog) => { gefragt.push(dialog.message()) await dialog.accept() }) await page.locator('form[action$="/loeschen"] button').click() expect(gefragt.join(' ')).toContain('löschen') await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht') await expect(page.locator('table.kontoblatt tbody')).not.toContainText('Supermarkt') }) test('Mehrere Buchungen über die Auswahl im Kontoblatt löschen', async ({ page }) => { for (const empfaenger of ['Sammellöschung A', 'Sammellöschung B']) { await page.goto(`/konten/${sparId}/buchung`) await page.selectOption('#art', 'ausgabe') await page.fill('#datum', '2026-04-02') await page.fill('#betrag', '9,90') await page.fill('#empfaenger', empfaenger) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() } await page.goto(`/konten/${sparId}`) // Ohne Auswahl bleibt der Knopf gesperrt. const knopf = page.locator('#buchungsauswahl button') await expect(knopf).toBeDisabled() // Das Ankreuzfeld in der Titelzeile wählt alle angezeigten Buchungen. const anzahl = await page.locator('table.kontoblatt tbody input[name="buchungen[]"]').count() await page.check('.alle-waehlen') await expect(knopf).toContainText(`(${anzahl})`) // Wie beim einzelnen Löschen hängt die Rückfrage an data-bestaetigen – // der Handler muss vor dem Klick stehen. 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.getByText('Sammellöschung')).toHaveCount(0) }) })