166 lines
6.8 KiB
JavaScript
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)
|
|
})
|
|
})
|