Finanzmanager/tests/e2e/specs/buchungen.spec.js
fds2610-bot ca8496f770 Kontoblatt: Klasse direkt auswählen, Zeilen einheitlich zweizeilig
Die Klassenspalte ist anklickbar wie die Zuordnung daneben: Ein Klick
öffnet die Klassenliste in der Zelle, die Auswahl wird sofort über
POST /buchungen/{id}/klasse gespeichert. Buchung::klasseSetzen() lässt
Kategorie, Gegenkonto und Splitzeilen unberührt.

Die Buchungszeilen sind jetzt alle zwei Textzeilen hoch, ihr Inhalt steht
senkrecht in der Mitte. Was nicht auf zwei Zeilen passt, wird
abgeschnitten; den vollständigen Text zeigt der Tooltip – bei Empfänger
und Buchungstext nur dort, wo wirklich etwas fehlt, über dem
Kategoriefeld einer Splitbuchung deren Zeilen mit Kategorie und Betrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:04:48 +02:00

447 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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')
})
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)
})
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)
})
})