Steuerkram/tests/e2e/specs/rechnung-export.spec.js

166 lines
6.8 KiB
JavaScript

// @ts-check
// Hotspot: Rechnung anlegen → finalisieren → PDF- und XML-Export.
//
// Der XML-Download (EN 16931 / XRechnung 3.0) kam zuletzt zweimal in Folge als
// Fix ins Repo — einmal der Export selbst, danach die USt-IdNr. des Kunden
// (BT-48), die im XML fehlte. Beide Male hing der Fehler am Zusammenspiel aus
// Controller, ZugferdService und den tatsächlich gespeicherten Rechnungsdaten.
//
// Hinweis: Der Finalisierungs-Test vergibt eine Rechnungsnummer, gibt sie beim
// Löschen der Testrechnung aber wieder frei (Invoice::delete). Der Nummernkreis
// steht nach dem Testlauf also wieder da, wo er vorher stand — der letzte Test
// dieser Datei weist genau das nach. Siehe README.md.
const { test, expect } = require('@playwright/test')
const { uniqueTag, gotoOk, acceptDialogs, cleanupInvoice, parseMoney } = require('../helpers/app')
/** Legt einen Rechnungsentwurf mit einer Position an und liefert die ID. */
async function entwurfAnlegen(page, { tag, einzelpreis = '100.00', ustSatz = '19' }) {
await gotoOk(page, '/invoices/new')
// Ersten echten Kunden wählen (Index 0 ist der Platzhalter).
const kundenOptionen = page.locator('#customer_id option')
const anzahl = await kundenOptionen.count()
expect(anzahl, 'Kein Kunde vorhanden — die Instanz braucht mindestens einen Kunden').toBeGreaterThan(1)
const kundenValue = await kundenOptionen.nth(1).getAttribute('value')
await page.selectOption('#customer_id', kundenValue)
await page.fill('#betreff', tag)
// Position über den "+"-Button und das Popup hinzufügen.
await page.click('button[onclick^="showAddLineDialog"]')
await page.click('#add-line-popup >> text=Neue Position')
const zeile = page.locator('#invoice-lines-body tr').last()
await zeile.locator('textarea[name$="[bezeichnung]"]').fill(`Leistung ${tag}`)
await zeile.locator('input[name$="[menge]"]').fill('1')
await zeile.locator('select[name$="[ust_satz]"]').selectOption(ustSatz)
await zeile.locator('input[name$="[einzelpreis]"]').fill(einzelpreis)
await Promise.all([
page.waitForURL(/\/invoices\/\d+/),
page.click('button[type="submit"]:has-text("Entwurf speichern")'),
])
const id = Number(page.url().match(/\/invoices\/(\d+)/)?.[1] || 0)
expect(id, `Rechnungs-ID nicht ermittelbar: ${page.url()}`).toBeGreaterThan(0)
return id
}
/** Finalisiert den Entwurf über den Button in der Detailansicht. */
async function finalisieren(page, invoiceId) {
acceptDialogs(page)
await page.click('button[type="submit"]:has-text("Finalisieren")')
await page.waitForLoadState('networkidle')
await expect(page.locator('body'), 'Finalisieren hat den Status nicht geändert')
.not.toContainText('Finalisieren & Nummerieren')
}
/**
* Liest die vergebene Rechnungsnummer aus dem Dateinamen des XML-Downloads.
* Sie steht nirgends sonst maschinell eindeutig auf der Seite.
*/
async function rechnungsnummerAusXml(page, invoiceId) {
const response = await page.request.get(`/invoices/${invoiceId}/xml`)
expect(response.status()).toBe(200)
const dateiname = response.headers()['content-disposition'] || ''
const nummer = dateiname.match(/Rechnung-(.+)\.xml/)?.[1]
expect(nummer, `Rechnungsnummer nicht aus Dateiname ermittelbar: ${dateiname}`).toBeTruthy()
expect(nummer).not.toBe('Entwurf')
return nummer
}
test.describe('Rechnung: Anlegen, Finalisieren, Export', () => {
let invoiceId = 0
test.afterEach(async ({ page }) => {
await cleanupInvoice(page, invoiceId)
invoiceId = 0
})
test('Entwurf wird mit Position und Summen gespeichert', async ({ page }) => {
const tag = uniqueTag()
invoiceId = await entwurfAnlegen(page, { tag })
await expect(page.locator('body')).toContainText(tag)
await expect(page.locator('body')).toContainText('Entwurf')
// 100,00 netto + 19 % = 119,00 brutto müssen irgendwo auf der Seite stehen.
await expect(page.locator('body')).toContainText('119,00')
})
test('XML-Export eines Entwurfs liefert wohlgeformtes XRechnung-XML', async ({ page }) => {
const tag = uniqueTag()
invoiceId = await entwurfAnlegen(page, { tag })
const response = await page.request.get(`/invoices/${invoiceId}/xml`)
expect(response.status()).toBe(200)
expect(response.headers()['content-type']).toContain('xml')
const xml = await response.text()
expect(xml.startsWith('<?xml'), 'Antwort ist kein XML-Dokument').toBe(true)
// Root-Element der CII-Syntax, die XRechnung 3.0 verwendet.
expect(xml).toContain('CrossIndustryInvoice')
// Kein unaufgelöstes PHP und keine Fehlerseite im Download.
expect(xml).not.toContain('<?php')
expect(xml).not.toContain('Ein Fehler ist aufgetreten')
})
test('Finalisieren vergibt eine Nummer und liefert PDF und XML aus', async ({ page }) => {
const tag = uniqueTag()
invoiceId = await entwurfAnlegen(page, { tag })
await finalisieren(page, invoiceId)
const seitentext = await page.locator('body').textContent()
expect(seitentext).toContain(tag)
// ── PDF ──
const pdf = await page.request.get(`/invoices/${invoiceId}/pdf`)
expect(pdf.status()).toBe(200)
const pdfBody = await pdf.body()
expect(pdfBody.subarray(0, 5).toString('latin1'), 'Kein gültiger PDF-Header').toBe('%PDF-')
expect(pdfBody.length).toBeGreaterThan(1000)
// ── XML ──
const xmlResponse = await page.request.get(`/invoices/${invoiceId}/xml`)
expect(xmlResponse.status()).toBe(200)
const xml = await xmlResponse.text()
expect(xml).toContain('CrossIndustryInvoice')
// Die vergebene Rechnungsnummer muss im XML stehen (BT-1).
const nummer = await rechnungsnummerAusXml(page, invoiceId)
expect(xml).toContain(nummer)
})
test('Löschen einer finalisierten Rechnung gibt die Nummer wieder frei', async ({ page }) => {
// Der Testlauf darf keine Nummer aus dem laufenden Kreis verbrauchen:
// Invoice::delete() zählt den Nummernkreis zurück, wenn die gelöschte
// Rechnung die zuletzt nummerierte war. Nachweis über die Nummer der
// nächsten Rechnung — sie muss dieselbe sein.
const ersteId = await entwurfAnlegen(page, { tag: uniqueTag() })
await finalisieren(page, ersteId)
const ersteNummer = await rechnungsnummerAusXml(page, ersteId)
await cleanupInvoice(page, ersteId)
await expect(
page.locator(`form[action="/invoices/${ersteId}/delete"]`),
'Die finalisierte Testrechnung wurde nicht gelöscht'
).toHaveCount(0)
invoiceId = await entwurfAnlegen(page, { tag: uniqueTag() })
await finalisieren(page, invoiceId)
expect(
await rechnungsnummerAusXml(page, invoiceId),
'Der Nummernkreis wurde beim Löschen nicht zurückgesetzt'
).toBe(ersteNummer)
})
test('PDF eines Entwurfs wird nicht ausgeliefert', async ({ page }) => {
const tag = uniqueTag()
invoiceId = await entwurfAnlegen(page, { tag })
// Im Entwurf gibt es bewusst keinen PDF-Link (GoBD: erst ab Finalisierung).
await expect(page.locator(`a[href="/invoices/${invoiceId}/pdf"]`)).toHaveCount(0)
})
})