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>
183 lines
6.7 KiB
JavaScript
183 lines
6.7 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'
|
||
|
||
// Eindeutige Namen je Lauf: die Tests arbeiten auf einer bestehenden Instanz
|
||
// und dürfen sich nicht an Resten eines früheren Laufs stoßen.
|
||
const MARKE = Date.now().toString().slice(-6)
|
||
|
||
async function anmelden(page) {
|
||
await page.goto('/login')
|
||
|
||
if (page.url().includes('/einrichtung')) {
|
||
throw new Error(
|
||
'Instanz ist noch nicht eingerichtet. Zuerst einen Benutzer anlegen: ' +
|
||
'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(/\/$/)
|
||
}
|
||
|
||
test.describe('Stammdaten', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await anmelden(page)
|
||
})
|
||
|
||
test('Kontenübersicht ist erreichbar', async ({ page }) => {
|
||
await page.click('.seitenleiste a[href="/konten"]')
|
||
|
||
await expect(page.locator('h1')).toContainText('Kontenübersicht')
|
||
await expect(page.locator('a[href="/konten/neu"]').first()).toBeVisible()
|
||
})
|
||
|
||
test('Konto anlegen und in der Übersicht wiederfinden', async ({ page }) => {
|
||
const name = `E2E Girokonto ${MARKE}`
|
||
|
||
await page.goto('/konten/neu')
|
||
await page.fill('#name', name)
|
||
await page.selectOption('#kontoart', 'bank')
|
||
await page.fill('#anfangssaldo', '1.234,56')
|
||
await page.click('.formular button[type="submit"]')
|
||
|
||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||
|
||
await page.goto('/konten')
|
||
const zeile = page.locator('tr', { hasText: name })
|
||
await expect(zeile).toContainText('Bank')
|
||
// Deutsches Betragsformat, geschütztes Leerzeichen vor dem Euro-Zeichen.
|
||
await expect(zeile).toContainText('1.234,56')
|
||
await expect(page.locator('.summenzeile')).toContainText('Summe Kontostände')
|
||
})
|
||
|
||
test('Kategorie mit Unterkategorie anlegen', async ({ page }) => {
|
||
const oben = `E2E Wohnen ${MARKE}`
|
||
const unten = `Strom ${MARKE}`
|
||
|
||
await page.goto('/verwaltung/kategorien/neu')
|
||
await page.fill('#name', oben)
|
||
await page.selectOption('#art', 'ausgabe')
|
||
await page.click('.formular button[type="submit"]')
|
||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||
|
||
// Nach dem Anlegen steht man im Bearbeitungsformular der Kategorie.
|
||
await expect(page.locator('.feldwert code')).toContainText(oben)
|
||
|
||
await page.goto('/verwaltung/kategorien/neu')
|
||
await page.fill('#name', unten)
|
||
await page.selectOption('#eltern_id', { label: oben })
|
||
await page.click('.formular button[type="submit"]')
|
||
|
||
await expect(page.locator('.feldwert code')).toContainText(`${oben}:${unten}`)
|
||
|
||
await page.goto('/verwaltung/kategorien')
|
||
await expect(page.locator('tr', { hasText: oben }).first()).toContainText('Oberkategorie')
|
||
// Die Unterkategorie ist um eine Ebene eingerückt.
|
||
await expect(page.locator(`tr:has-text("${unten}") .baumname`))
|
||
.toHaveAttribute('style', /--ebene: 1/)
|
||
})
|
||
|
||
test('Register filtern nach Einnahmen und Ausgaben', async ({ page }) => {
|
||
const einnahme = `E2E Gehalt ${MARKE}`
|
||
|
||
await page.goto('/verwaltung/kategorien/neu')
|
||
await page.fill('#name', einnahme)
|
||
await page.selectOption('#art', 'einnahme')
|
||
await page.click('.formular button[type="submit"]')
|
||
|
||
await page.goto('/verwaltung/kategorien?register=einnahmen')
|
||
await expect(page.locator('.tab.aktiv')).toContainText('Einnahmen')
|
||
await expect(page.locator('tbody')).toContainText(einnahme)
|
||
|
||
await page.goto('/verwaltung/kategorien?register=ausgaben')
|
||
await expect(page.locator('tbody')).not.toContainText(einnahme)
|
||
})
|
||
|
||
test('Umbuchungskonten zeigen die Konten in eckigen Klammern', async ({ page }) => {
|
||
await page.goto('/verwaltung/kategorien?register=umbuchungskonten')
|
||
|
||
await expect(page.locator('.tab.aktiv')).toContainText('Umbuchungskonten')
|
||
await expect(page.locator('tbody a').first()).toContainText(/^\[.+\]$/)
|
||
})
|
||
|
||
test('Klasse anlegen und wieder löschen', async ({ page }) => {
|
||
const name = `E2E Projekt ${MARKE}`
|
||
|
||
await page.goto('/verwaltung/klassen')
|
||
await page.fill('.formular #name', name)
|
||
await page.fill('.formular #beschreibung', 'aus dem E2E-Test')
|
||
await page.click('.formular button[type="submit"]')
|
||
|
||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||
|
||
// Die Liste ist bearbeitbar: Name und Beschreibung stehen in
|
||
// Eingabefeldern, nicht als Text – deshalb über den Attributwert suchen.
|
||
const zeile = page.locator('tr').filter({
|
||
has: page.locator(`input[name="name"][value="${name}"]`),
|
||
})
|
||
await expect(zeile).toHaveCount(1)
|
||
await expect(zeile.locator('input[name="beschreibung"]')).toHaveValue('aus dem E2E-Test')
|
||
|
||
// Den Dialog sofort schließen: solange er offen ist, kehrt click() nicht
|
||
// zurück. Die Bestätigung kommt aus /static/app.js (data-bestaetigen).
|
||
page.on('dialog', async (dialog) => await dialog.accept())
|
||
await zeile.getByRole('button', { name: 'Löschen' }).click()
|
||
|
||
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
|
||
})
|
||
|
||
test('QIF-Trockenlauf zeigt an, was ankäme, ohne zu speichern', async ({ page }) => {
|
||
const dateiname = `e2e-probe-${MARKE}.qif`
|
||
const klasse = `E2E Klasse ${MARKE}`
|
||
const inhalt = [
|
||
'!Option:MDY',
|
||
'!Type:Class',
|
||
`N${klasse}`,
|
||
'^',
|
||
'!Type:Cat',
|
||
`NE2E Kategorie ${MARKE}`,
|
||
'E',
|
||
'^',
|
||
'!Option:AutoSwitch',
|
||
'!Account',
|
||
`NE2E Konto ${MARKE}`,
|
||
'TBank',
|
||
'^',
|
||
'!Clear:AutoSwitch',
|
||
'',
|
||
].join('\r\n')
|
||
|
||
await page.goto('/verwaltung/import')
|
||
await page.setInputFiles('#qif', {
|
||
name: dateiname,
|
||
mimeType: 'application/octet-stream',
|
||
buffer: Buffer.from(inhalt, 'latin1'),
|
||
})
|
||
await page.click('.formular button[type="submit"]')
|
||
await expect(page.locator('.meldung-erfolg')).toContainText('hochgeladen')
|
||
|
||
const zeile = page.locator('tr', { hasText: dateiname })
|
||
await zeile.getByRole('button', { name: 'Trockenlauf' }).click()
|
||
|
||
await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf')
|
||
const uebersicht = page.locator('.kachel table.liste').first()
|
||
await expect(uebersicht.locator('tr', { hasText: 'Klassen' })).toContainText('1')
|
||
await expect(uebersicht.locator('tr', { hasText: 'Konten' })).toContainText('1')
|
||
|
||
// Nichts gespeichert: die Klasse darf noch nicht in der Liste stehen.
|
||
await page.goto('/verwaltung/klassen')
|
||
await expect(page.locator('body')).not.toContainText(klasse)
|
||
|
||
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
|
||
await page.goto('/verwaltung/import')
|
||
page.on('dialog', async (dialog) => await dialog.accept())
|
||
await page.locator('tr', { hasText: dateiname })
|
||
.getByRole('button', { name: 'Löschen' }).click()
|
||
await expect(page.locator('body')).not.toContainText(dateiname)
|
||
})
|
||
})
|