// @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}` 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.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') }) 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('#kategorie_id', { 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`) await page.selectOption('#art', 'umbuchung_ab') await page.fill('#datum', '2026-04-01') await page.fill('#betrag', '500') await page.fill('#empfaenger', `Übertrag ${MARKE}`) await page.selectOption('#gegenkonto_id', { 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. 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(page.locator('.saldoanzeige').first()).toContainText('5.500,00') }) 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][kategorie_id]"]', { 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][kategorie_id]"]', { 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') }) 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][kategorie_id]"]', { 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"]') await expect(page.locator('tbody tr')).toHaveCount(1) await expect(page.locator('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('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('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('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) }) })