Der Plan ging davon aus, Umbuchungen stünden zweimal im Export und müssten zusammengeführt werden. Der Vollexport zeigt das Gegenteil: Eine Umbuchung steht einmal, in dem Konto, in dem sie erfasst wurde; die Gegenseite ist nur durch L[Zielkonto] benannt und muss beim Import erzeugt werden. Von 965 Umbuchungen hat keine ein zweites Vorkommen. Mit erzeugter Gegenseite stimmen acht gegen den FinanzManager geprüfte Kontosalden auf den Cent. Datenmodell: Migration 003 mit buchungen, buchung_splits und buchungsadressen. Eine Umbuchung ist ein Datensatz mit gegenkonto_id, kein gespiegeltes Paar - zwei Zeilen laufen früher oder später auseinander. Das Kontoblatt des Gegenkontos zeigt sie mit umgekehrtem Vorzeichen. Auch einzelne Splitzeilen dürfen ein Gegenkonto tragen. Konten führen zusätzlich in_summe, die Sigma-Spalte der Kontenübersicht, und saldo_online für Phase 5. Oberfläche: Kontoblatt mit laufendem Saldo, Filter auf Zeitraum, Kategorie, Klasse und Abgleich, Volltextsuche und Seitenblättern. Buchungsmaske für Einnahme, Ausgabe und Umbuchung in beide Richtungen, mit Splitzeilen und Restbetragsanzeige. Import: QifBuchungsImport arbeitet mit Stichtag. Alles davor wird je Konto zum Anfangssaldo aufsummiert, Gegenbuchungen eingerechnet; alles danach bis zum Importtag kommt einzeln in die Datenbank. Spätere Buchungen sind Vorausberechnungen des FinanzManagers und bleiben draußen. Wertpapiergeschäfte schlagen vorerst nur mit ihrem Geldfluss auf dem Verrechnungskonto zu Buche. Am Vollexport geprüft: 833 Buchungen ab 01.01.2026, 7946 in die Anfangssalden von 35 Konten verrechnet. Behoben: Die confirm()-Rückfragen vor dem Löschen waren wirkungslos. public/static/app.js brachte den Mechanismus mit, war aber nie im Layout eingebunden; die ersatzweise gesetzten onsubmit-Attribute verstoßen gegen die eigene Content-Security-Policy und wurden vom Browser verworfen - gelöscht wurde ohne Rückfrage. Ein E2E-Test besteht jetzt auf dem Dialog. Tests: 307 PHP-Prüfungen und 21 Playwright-Tests, alle grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
189 lines
6.9 KiB
JavaScript
189 lines
6.9 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.
|
||
await expect(page.locator('tbody tr td').nth(7)).toContainText('–')
|
||
})
|
||
|
||
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')
|
||
})
|
||
})
|