Der Überweisungsdialog bietet das Häkchen „Echtzeitüberweisung“. SEPA behandelt sie als eigenes Verfahren: Über FinTS geht der Auftrag als HKIPZ (SendSEPARealtimeTransfer) statt als HKCCS hinaus, die pain.001-Nachricht trägt die Zahlungsart LclInstrm/Cd = INST (nur im ISO-Format pain.001.001.03). Eine Echtzeitüberweisung hat keinen Ausführungstermin; bietet die Bank sie nicht an, scheitert der Auftrag mit klarer Meldung, eine stille Umwandlung in eine gewöhnliche Überweisung ist abgeschaltet. Neue Spalte zahlungsauftraege.echtzeit (Migration 019), Test 43 und ein E2E-Test für das Sperren des Termins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
368 lines
15 KiB
JavaScript
368 lines
15 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'
|
||
|
||
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(/\/$/)
|
||
}
|
||
|
||
/**
|
||
* Die Module aus den Phasen 4 bis 7: Cockpit-Kacheln, Online-Banking,
|
||
* Wertpapiere und die Nebenmodule.
|
||
*
|
||
* Geprüft wird, dass jeder Bereich erreichbar ist und seine Kernansicht
|
||
* aufbaut – nicht die Rechenlogik. Die steckt in der PHP-Suite, wo sie
|
||
* schneller und isoliert läuft.
|
||
*/
|
||
test.describe('Module der Phasen 4 bis 7', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await anmelden(page)
|
||
})
|
||
|
||
test('Jeder Navigationspunkt führt in ein gebautes Modul', async ({ page }) => {
|
||
const punkte = [
|
||
['/konten', 'Kontenübersicht'],
|
||
['/banking', 'Bankverbindungen'],
|
||
['/zahlungsverkehr', 'Zahlungsverkehr'],
|
||
['/auswertungen', 'Auswertungen'],
|
||
['/planung', 'Planung'],
|
||
['/termine', 'Regelmäßige Buchungen'],
|
||
['/vertraege', 'Verträge'],
|
||
['/wertpapiere', 'Wertpapiere'],
|
||
['/verwaltung', 'Verwaltung'],
|
||
]
|
||
|
||
// Jeder Bereich hat sein Modul: Die Überschrift gehört zum Modul, nicht
|
||
// zur früheren Platzhalterseite.
|
||
for (const [pfad, titel] of punkte) {
|
||
await page.goto(pfad)
|
||
await expect(page.locator('h1')).toContainText(titel)
|
||
}
|
||
})
|
||
|
||
test('Zahlenspalten stehen in allen Listen rechtsbündig', async ({ page }) => {
|
||
// `.rechts` markiert Zahlen- und Aktionsspalten. Die Klasse muss die
|
||
// linksbündige Grundregel der Listen überall schlagen – nicht nur in den
|
||
// Berichten, wo es zuerst auffiel.
|
||
for (const pfad of ['/konten', '/wertpapiere', '/wertpapiere/buchungen']) {
|
||
await page.goto(pfad)
|
||
|
||
for (const art of ['th', 'td']) {
|
||
const zellen = page.locator(`table.liste ${art}.rechts`)
|
||
const anzahl = await zellen.count()
|
||
expect(anzahl, `${pfad}: ${art}.rechts vorhanden`).toBeGreaterThan(0)
|
||
|
||
for (const stelle of [0, anzahl - 1]) {
|
||
await expect(zellen.nth(stelle)).toHaveCSS('text-align', 'right')
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
test('Das Cockpit zeichnet seine Kacheln', async ({ page }) => {
|
||
await page.goto('/')
|
||
|
||
// Über die Überschriften, nicht über den Kacheltext: „Kontostände“ kommt
|
||
// auch im Hinweis der Vermögenskachel vor.
|
||
const ueberschriften = page.locator('.kachel h2')
|
||
for (const titel of [
|
||
'Vermögensentwicklung', 'Einnahmen und Ausgaben', 'Meine Ausgaben', 'Kontostände',
|
||
]) {
|
||
await expect(ueberschriften.filter({ hasText: titel })).toHaveCount(1)
|
||
}
|
||
})
|
||
|
||
test('Abgewählte Kacheln verschwinden und kommen zurück', async ({ page }) => {
|
||
await page.goto('/einstellungen?bereich=darstellung')
|
||
|
||
const kachel = page.locator('input[name="cockpit_kacheln[]"][value="ausgaben"]')
|
||
await kachel.uncheck()
|
||
await page.click('button:has-text("Speichern")')
|
||
|
||
await page.goto('/')
|
||
await expect(page.locator('.kachel h2', { hasText: 'Meine Ausgaben' })).toHaveCount(0)
|
||
await expect(page.locator('.kachelfuss')).toContainText('abgeschaltet')
|
||
|
||
await page.goto('/einstellungen?bereich=darstellung')
|
||
await page.locator('input[name="cockpit_kacheln[]"][value="ausgaben"]').check()
|
||
await page.click('button:has-text("Speichern")')
|
||
|
||
await page.goto('/')
|
||
await expect(page.locator('.kachel h2', { hasText: 'Meine Ausgaben' })).toHaveCount(1)
|
||
})
|
||
|
||
test('Eine Bankverbindung lässt sich anlegen und wieder löschen', async ({ page }) => {
|
||
// Vor den drei Einrichtungsschritten steht die Wahl der Art; die
|
||
// Bankenmaske hängt an der ersten der beiden Karten.
|
||
await page.goto('/banking/neu')
|
||
await page.click('.formatkarte:has-text("HBCI/FinTS")')
|
||
|
||
await page.fill('#name', 'E2E-Testbank')
|
||
await page.fill('#blz', '12030000')
|
||
await page.fill('#fints_url', 'https://banking.example.org/fints')
|
||
await page.fill('#benutzerkennung', 'e2e-kunde')
|
||
await page.fill('#pin', 'geheim')
|
||
|
||
// Schritt 1 speichert nur die Zugangsdaten.
|
||
await expect(page.locator('.schrittmarke')).toContainText('Schritt 1 von 3')
|
||
await page.click('button:has-text("Weiter")')
|
||
|
||
// Schritt 2 fragt die Bank selbst nach ihren TAN-Verfahren. Die Adresse
|
||
// im Test führt nirgendwohin – geprüft wird deshalb der Fehlerweg: Die
|
||
// Seite erklärt, was zu tun ist, statt stehen zu bleiben.
|
||
await expect(page.locator('.schrittmarke')).toContainText('Schritt 2 von 3')
|
||
await expect(page.locator('h1')).toContainText('E2E-Testbank')
|
||
await expect(page.locator('.meldung-fehler')).toBeVisible()
|
||
await expect(page.locator('a:has-text("Zugangsdaten prüfen")')).toHaveCount(1)
|
||
|
||
// Wer die Einrichtung vorzeitig verlässt, landet in der Übersicht: Die
|
||
// Zugangsmaske böte nur noch an, was gerade durchlaufen wurde.
|
||
await page.click('a:has-text("Später einstellen")')
|
||
await expect(page.locator('h1')).toContainText('Bankverbindungen')
|
||
await expect(page.locator('td a:has-text("E2E-Testbank")')).toHaveCount(1)
|
||
|
||
await page.click('td a:has-text("E2E-Testbank")')
|
||
await expect(page.locator('h1')).toContainText('E2E-Testbank')
|
||
|
||
// Die PIN wird nie wieder angezeigt.
|
||
await expect(page.locator('#pin')).toHaveValue('')
|
||
await expect(page.locator('#pin')).toHaveAttribute('placeholder', /unverändert/)
|
||
|
||
// Ohne Wahl bleibt der Hinweis stehen, und die Seite ist wieder erreichbar.
|
||
await expect(page.locator('a:has-text("TAN-Verfahren einstellen")')).toHaveCount(1)
|
||
|
||
// Der Stichtag gibt es zweimal: als Vorgabe des Zugangs …
|
||
await page.selectOption('#abrufzeitraum', 'stichtag')
|
||
await page.fill('#abruf_stichtag', '2026-01-01')
|
||
await page.click('button:has-text("Speichern")')
|
||
await expect(page.locator('.meldung')).toContainText('gespeichert')
|
||
await expect(page.locator('#abruf_stichtag')).toHaveValue('2026-01-01')
|
||
|
||
// … und als einmalige Angabe neben dem Abrufknopf, die nichts speichert.
|
||
await expect(page.locator('.abrufform input[name="stichtag"]')).toHaveCount(1)
|
||
|
||
await page.goto('/banking')
|
||
await expect(page.locator('tr:has-text("E2E-Testbank")')).toContainText('Ab Stichtag')
|
||
await expect(page.locator('tr:has-text("E2E-Testbank")')).toContainText('01.01.2026')
|
||
await page.click('td a:has-text("E2E-Testbank")')
|
||
|
||
// Schritt 3: die Kontenzuordnung. Ohne erreichbare Bank kennt sie noch
|
||
// kein Konto und bietet die Abfrage an, statt eine leere Tabelle zu zeigen.
|
||
await page.click('a:has-text("Konten zuordnen")')
|
||
await expect(page.locator('h1')).toContainText('Konten zuordnen')
|
||
await expect(page.locator('.platzhalter')).toContainText('Noch kein Konto bekannt')
|
||
await expect(page.locator('button:has-text("Konten bei der Bank abfragen")')).toHaveCount(1)
|
||
|
||
await page.click('a:has-text("Zurück zur Bankverbindung")')
|
||
await expect(page.locator('h1')).toContainText('E2E-Testbank')
|
||
|
||
page.once('dialog', dialog => dialog.accept())
|
||
await page.click('button:has-text("Bankverbindung löschen")')
|
||
await expect(page.locator('.meldung')).toContainText('gelöscht')
|
||
})
|
||
|
||
test('Eine falsche IBAN wird beim Anlegen einer Adresse abgewiesen', async ({ page }) => {
|
||
await page.goto('/vertraege/adressen')
|
||
|
||
await page.fill('#name', 'E2E-Falschtest')
|
||
await page.fill('#iban', 'DE02120300000000202052')
|
||
await page.click('button:has-text("Adresse anlegen")')
|
||
|
||
await expect(page.locator('.meldung-fehler')).toContainText('IBAN')
|
||
})
|
||
|
||
test('Ein Kredit zeigt seinen gerechneten Tilgungsplan', async ({ page }) => {
|
||
await page.goto('/vertraege/kredite')
|
||
|
||
await page.fill('#name', 'E2E-Darlehen')
|
||
await page.fill('#darlehensbetrag', '100000')
|
||
await page.fill('#zinssatz', '3,0')
|
||
await page.fill('#rate', '500')
|
||
await page.click('button:has-text("Kredit anlegen")')
|
||
|
||
await expect(page.locator('h3', { hasText: 'Tilgungsplan' })).toContainText('E2E-Darlehen')
|
||
await expect(page.locator('.kennzahltitel', { hasText: 'Zinsen gesamt' })).toHaveCount(1)
|
||
|
||
// Erste Rate: 250 Zinsen, 250 Tilgung. Die erste Tabelle ist die
|
||
// Kreditliste, der Plan steht in der zweiten.
|
||
const ersteRate = page.locator('table.liste').last().locator('tbody tr').first()
|
||
await expect(ersteRate).toContainText('250,00')
|
||
|
||
page.once('dialog', dialog => dialog.accept())
|
||
await page.locator('tr', { hasText: 'E2E-Darlehen' }).locator('button:has-text("Löschen")').first().click()
|
||
await expect(page.locator('.meldung')).toContainText('gelöscht')
|
||
})
|
||
|
||
test('Depotbuchungen lassen sich über die Auswahl sammelweise löschen', async ({ page }) => {
|
||
const marke = Date.now().toString().slice(-6)
|
||
const depot = `E2E Depot ${marke}`
|
||
const papier = `E2E Papier ${marke}`
|
||
|
||
await page.goto('/konten/neu')
|
||
await page.fill('#name', depot)
|
||
await page.selectOption('#kontoart', 'depot')
|
||
await page.click('.formular button[type="submit"]')
|
||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||
|
||
// Zwei Käufe desselben Papiers – der zweite über die Auswahlliste, damit
|
||
// kein zweites Wertpapier entsteht.
|
||
for (const [nr, stueck] of [[1, '10'], [2, '5']]) {
|
||
await page.goto('/wertpapiere/buchungen/neu')
|
||
await page.selectOption('#konto_id', { label: depot })
|
||
await page.fill('#datum', '2026-05-0' + nr)
|
||
if (nr === 1) {
|
||
await page.fill('#wertpapier_name', papier)
|
||
} else {
|
||
await page.selectOption('#wertpapier_id', { label: papier })
|
||
}
|
||
await page.fill('#stueck', stueck)
|
||
await page.fill('#kurs', '100')
|
||
await page.click('.formular button[type="submit"]')
|
||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||
}
|
||
|
||
// Der Filter bleibt über das Löschen hinweg stehen – das Formularziel
|
||
// trägt ihn mit.
|
||
await page.goto('/wertpapiere/buchungen')
|
||
await page.selectOption('select[name="depot"]', { label: depot })
|
||
await page.click('button:has-text("Filtern")')
|
||
await expect(page.locator('tbody tr')).toHaveCount(2)
|
||
|
||
// Der Bestand kennt beide Käufe.
|
||
await page.goto('/wertpapiere')
|
||
await page.selectOption('select[name="depot"]', { label: depot })
|
||
await page.click('button:has-text("Anzeigen")')
|
||
await expect(page.locator('tbody tr', { hasText: papier })).toContainText('15')
|
||
|
||
await page.goBack()
|
||
await page.goBack()
|
||
|
||
// Ohne Auswahl bleibt der Knopf gesperrt.
|
||
const knopf = page.locator('#depotbuchungsauswahl button')
|
||
await expect(knopf).toBeDisabled()
|
||
|
||
await page.check('.alle-waehlen')
|
||
await expect(knopf).toContainText('(2)')
|
||
|
||
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).toHaveURL(/depot=/)
|
||
// Der Name steht weiter im Filterfeld – gemeint ist die Liste.
|
||
await expect(page.locator('table.liste')).toHaveCount(0)
|
||
await expect(page.locator('.platzhalter')).toContainText('Keine Depotbuchungen')
|
||
|
||
// Und der Bestand ist die Buchungen los – er wird gerechnet.
|
||
await page.goto('/wertpapiere')
|
||
await page.selectOption('select[name="depot"]', { label: depot })
|
||
await page.click('button:has-text("Anzeigen")')
|
||
await expect(page.locator('.platzhalter')).toContainText('Keine Bestände')
|
||
|
||
// Aufräumen: Das Testdepot wieder entfernen. Die Rückfrage übernimmt der
|
||
// Dialoghandler von oben.
|
||
await page.goto('/konten')
|
||
const pfad = await page.locator('a', { hasText: depot }).first().getAttribute('href')
|
||
await page.goto(pfad + '/bearbeiten')
|
||
await page.locator('button:has-text("Konto löschen")').click()
|
||
await expect(page.locator('.meldung')).toContainText('gelöscht')
|
||
})
|
||
|
||
test('Der Finanzkalender ist erreichbar und lässt sich einstellen', async ({ page }) => {
|
||
await page.goto('/termine/kalender')
|
||
await expect(page.locator('h1')).toContainText('Finanzkalender')
|
||
|
||
await page.selectOption('#tage', '90')
|
||
await page.click('button:has-text("Anzeigen")')
|
||
await expect(page).toHaveURL(/tage=90/)
|
||
})
|
||
|
||
test('Der Zahlungsverkehr weist eine falsche IBAN ab', async ({ page }) => {
|
||
await page.goto('/zahlungsverkehr/neu')
|
||
|
||
await page.fill('#empfaenger', 'E2E-Empfänger')
|
||
await page.fill('#iban', 'DE00000000000000000000')
|
||
await page.fill('#betrag', '10,00')
|
||
await page.click('button:has-text("Erfassen")')
|
||
|
||
// Die Bankverbindung ist bewusst nicht Pflicht: Ein Auftrag darf erfasst
|
||
// werden, bevor eine eingerichtet ist. Geprüft wird sie erst beim Senden.
|
||
await expect(page.locator('.meldung-fehler')).toContainText('IBAN')
|
||
})
|
||
|
||
test('Die Echtzeitüberweisung sperrt den Ausführungstermin', async ({ page }) => {
|
||
await page.goto('/zahlungsverkehr/neu')
|
||
|
||
await page.fill('#ausfuehrung_am', '2099-09-05')
|
||
await page.check('#echtzeit')
|
||
|
||
// Eine Echtzeitüberweisung geht sofort hinaus – der Termin wird geleert
|
||
// und gesperrt, solange sie gewählt ist.
|
||
await expect(page.locator('#ausfuehrung_am')).toBeDisabled()
|
||
await expect(page.locator('#ausfuehrung_am')).toHaveValue('')
|
||
|
||
await page.uncheck('#echtzeit')
|
||
await expect(page.locator('#ausfuehrung_am')).toBeEnabled()
|
||
})
|
||
|
||
test('Eine Überweisung übernimmt den Empfänger auf Wunsch ins Adressbuch', async ({ page }) => {
|
||
const empfaenger = `E2E-Adressbuch ${Date.now()}`
|
||
|
||
await page.goto('/zahlungsverkehr/neu')
|
||
await page.fill('#empfaenger', empfaenger)
|
||
await page.fill('#iban', 'DE02120300000000202051')
|
||
await page.fill('#betrag', '12,34')
|
||
await page.check('input[name="adresse_merken"]')
|
||
await page.click('button:has-text("Erfassen")')
|
||
|
||
await expect(page.locator('.meldung-erfolg')).toContainText('erfasst')
|
||
|
||
// Die Adresse landet wirklich im Adressbuch – nicht nur der Auftrag.
|
||
// Die Liste ist eine Bearbeitungstabelle: Die Namen stehen in
|
||
// Eingabefeldern, nicht als Text.
|
||
await page.goto('/vertraege/adressen')
|
||
await expect(page.locator(`table.liste input[name="name"][value="${empfaenger}"]`))
|
||
.toHaveCount(1)
|
||
})
|
||
|
||
test('Die Empfängerauswahl im Zahlungsverkehr ist ein Typeahead aus dem Adressbuch', async ({ page }) => {
|
||
const empfaenger = `E2E-Typeahead ${Date.now()}`
|
||
|
||
// Erst eine Adresse anlegen, damit die Auswahl etwas zu bieten hat.
|
||
await page.goto('/vertraege/adressen')
|
||
await page.fill('#name', empfaenger)
|
||
await page.fill('#iban', 'DE02120300000000202051')
|
||
await page.click('button:has-text("Adresse anlegen")')
|
||
await expect(page.locator('.meldung-erfolg')).toContainText('angelegt')
|
||
|
||
await page.goto('/zahlungsverkehr/neu')
|
||
|
||
// kombobox.js ersetzt das <select> optisch durch ein Eingabefeld.
|
||
const eingabe = page.locator('.kombobox-eingabe')
|
||
await eingabe.click()
|
||
await eingabe.fill(empfaenger)
|
||
await page.locator('.kombobox-option', { hasText: empfaenger }).click()
|
||
|
||
await expect(page.locator('#iban')).toHaveValue('DE02120300000000202051')
|
||
})
|
||
})
|