Finanzmanager/tests/e2e/specs/buchungen.spec.js
fds2610-bot 78f1b91bc0 Online-Banking: Kontenzuordnung, Sammellöschen und drei Umsatz-Fehler
Kontenzuordnung als dritter Einrichtungsschritt: Welches Bankkonto gehört zu
welchem eigenen Konto? Umsätze merken sich über `bank_konto_id`, woher sie
stammen, und ziehen mit, wenn die Zuordnung erst nach einem Abruf fällt.
Abgerufene Umsätze buchen sich auf Wunsch selbst (`Services\Bankabgleich`).

Im Kontoblatt lassen sich Buchungen sammelweise löschen; Statusmeldungen liegen
jetzt über der Ansicht, statt den Inhalt springen zu lassen.

Dazu drei Fehler, die zusammen dafür sorgten, dass abgerufene Umsätze spurlos
verschwanden – der Abruf meldete sie als vorhanden, das Kontoblatt zeigte
nichts, und in der Vorschlagsliste standen sie ebenfalls nicht:

- Eine gelöschte Buchung ließ ihren Umsatz auf „uebernommen“ stehen. Der
  Fremdschlüssel setzte nur `buchung_id` auf NULL. `Buchung::loeschen()` gibt
  den Umsatz jetzt vorher frei, `Bankumsatz::verwaisteOeffnen()` fängt die Wege
  ab, die daran vorbeiführen – Kaskaden lösen in MariaDB keine Trigger aus.
- Ein Bankkonto entstand doppelt, wenn die Bank die IBAN nicht bei jedem Dialog
  mitliefert. Gesucht wird jetzt über die Kontonummer; eine nachgelieferte IBAN
  wird am vorhandenen Eintrag nachgetragen.
- Der Fingerabdruck hing an der IBAN und kippte mit ihr. Er nimmt jetzt die
  `bank_konto_id`, die fest steht, solange es den Bankzugang gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:54:23 +02:00

248 lines
9.3 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}`
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)
})
})