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>
248 lines
9.3 KiB
JavaScript
248 lines
9.3 KiB
JavaScript
// @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)
|
||
})
|
||
})
|