Files

225 lines
11 KiB
Python

"""Vorlagen, GUI-Zustände und optional die vollständige echte Rechnung prüfen."""
import calendar
from datetime import date
import importlib.util
import logging
import os
from pathlib import Path
import tempfile
import time
import unittest
from unittest.mock import patch
import yaml
from jr_einvoice.calc import compute_totals
from jr_einvoice.create import create_invoice, save_customer
from jr_einvoice.models import Invoice
# Beim Rollback ältere Laufzeiten mit ihrer tatsächlich verfügbaren Funktion prüfen.
HAS_GUI_DATA = importlib.util.find_spec('jr_einvoice.gui_data') is not None
if HAS_GUI_DATA:
from jr_einvoice.gui_data import InvoiceForm, InvoiceTemplate, load_template, save_template
ROOT = Path(__file__).resolve().parent.parent
SAMPLE = Invoice.model_validate(yaml.safe_load((ROOT / 'examples/invoice.sample.yaml').read_text()))
HAS_QT = importlib.util.find_spec('PySide6') is not None
@unittest.skipUnless(HAS_GUI_DATA, 'Diese Laufzeit enthält noch keinen Desktop-Assistenten')
class TemplateTest(unittest.TestCase):
def test_roundtrip_excludes_old_number_dates_and_bank_details(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
template = load_template(ROOT / 'examples/invoice.sample.yaml')
path = save_template(root / 'templates', 'IT-Beratung', template)
self.assertEqual(load_template(path), template)
data = yaml.safe_load(path.read_text())
for excluded in ('meta', 'number', 'issued', 'start', 'end', 'seller', 'payment'):
self.assertNotIn(excluded, data)
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700)
with self.assertRaises(FileExistsError):
save_template(path.parent, 'IT-Beratung', template)
save_template(path.parent, 'IT-Beratung', template, overwrite=True)
for name in ('../escape', '/tmp/escape', '', 'a/b', '..', 'trailing.'):
with self.assertRaises(ValueError):
save_template(path.parent, name, template)
def test_invalid_template_version_and_position_rejected(self):
template = load_template(ROOT / 'examples/invoice.sample.yaml')
for changes in ({'schema_version': 2}, {'items': []}, {'old_number': '2020-1234'}):
with self.assertRaises(ValueError):
InvoiceTemplate.model_validate(template.model_dump() | changes)
def test_multiple_items_custom_period_and_notes_use_shared_core(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
presets = root / 'presets'
presets.mkdir()
(presets / 'seller.default.yaml').write_text(yaml.safe_dump({
'seller': SAMPLE.seller.model_dump(mode='json'), 'payment': SAMPLE.payment.model_dump(mode='json')}))
save_customer(presets, SAMPLE.buyer.name, SAMPLE.buyer.model_dump(mode='json'))
items = SAMPLE.items
form = InvoiceForm(buyer=SAMPLE.buyer, items=items, start=date(2026, 9, 10),
end=date(2026, 9, 20), notes='Individuelle Hinweise', issued=date(2026, 10, 19))
invoice = form.invoice(presets)
self.assertEqual(compute_totals(invoice).gross_total, compute_totals(SAMPLE).gross_total)
jar = root / 'mustang.jar'
jar.touch()
# Abbruch genau am Übergang zur PDF-Erzeugung: gespeicherte Daten prüfen.
with patch('jr_einvoice.create.build_in_folder', side_effect=RuntimeError('Testabbruch')):
with self.assertRaises(RuntimeError):
create_invoice(root=root / 'invoices', resources=ROOT, presets=presets,
customer=SAMPLE.buyer.name, entry='Oktober 2026', volume=1, price=1,
items=items, notes=form.notes, jar=jar, logger=logging.getLogger('test'),
service_period_start=form.start, service_period_end=form.end)
saved = Invoice.model_validate(yaml.safe_load(next((root / 'invoices').glob('*/invoice.yaml')).read_text()))
self.assertEqual(saved.items, items)
self.assertEqual(saved.meta.service_period_start, form.start)
self.assertEqual(saved.meta.service_period_end, form.end)
self.assertEqual(saved.meta.notes, form.notes)
@unittest.skipUnless(HAS_QT and HAS_GUI_DATA, 'Diese Laufzeit enthält keine installierte Desktop-Oberfläche')
class GuiTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
from PySide6.QtWidgets import QApplication
cls.app = QApplication.instance() or QApplication([])
cls.app.setStyle('Fusion')
def setUp(self):
from jr_einvoice.gui import InvoiceWindow
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.home = Path(self.tmp.name)
self.env = patch.dict(os.environ, {'JR_EINVOICE_HOME': str(self.home)})
self.env.start()
self.addCleanup(self.env.stop)
presets = self.home / 'presets'
presets.mkdir()
(presets / 'seller.default.yaml').write_text(yaml.safe_dump({
'seller': SAMPLE.seller.model_dump(mode='json'), 'payment': SAMPLE.payment.model_dump(mode='json')}))
self.jar = Path(os.environ.get('JR_EINVOICE_MUSTANG_JAR', ROOT / 'tools/mustang/Mustang-CLI.jar'))
self.window = InvoiceWindow(root=self.home / 'invoices', presets=presets, jar=self.jar)
self.addCleanup(self.cleanup_window)
self.window.confirm_replace = lambda: True
self.errors = []
self.window.error = self.errors.append
def cleanup_window(self):
self.wait_worker()
self.window.dirty = False
self.window.close()
self.window.deleteLater()
self.app.processEvents()
def wait_worker(self):
from PySide6.QtTest import QTest
deadline = time.monotonic() + 150
while self.window.worker is not None and time.monotonic() < deadline:
self.app.processEvents()
QTest.qWait(20)
self.assertIsNone(self.window.worker, 'Hintergrundauftrag nicht rechtzeitig abgeschlossen')
self.app.processEvents()
def load_sample(self):
self.window.apply_template(ROOT / 'examples/invoice.sample.yaml')
self.assertFalse(self.errors)
def test_load_earlier_invoice_resets_dates_but_preserves_details(self):
self.load_sample()
form = self.window.form()
self.assertEqual(form.issued, date.today())
self.assertEqual(form.start, date.today().replace(day=1))
self.assertEqual(form.end.day, calendar.monthrange(date.today().year, date.today().month)[1])
self.assertIsNone(form.number)
self.assertEqual(form.buyer, SAMPLE.buyer)
self.assertEqual(form.items, SAMPLE.items)
self.window.positions[0].quantity.setText('7,5')
self.window.positions[0].price.setText('1.234,56 €')
self.assertEqual(str(self.window.form().items[0].unit_price_net), '1234.56')
self.assertIn('Brutto', self.window.totals.text())
def test_invalid_fields_do_not_save_customer_or_reserve_number(self):
self.load_sample()
self.window.positions[0].quantity.setText('-1')
self.window.generate()
self.assertTrue(self.errors)
self.assertIsNone(self.window.worker)
self.assertFalse(self.window.root.exists())
self.assertEqual(list((self.window.presets / 'customers').glob('*.yaml')), [])
def test_existing_customer_cannot_be_silently_overwritten(self):
save_customer(self.window.presets, SAMPLE.buyer.name,
SAMPLE.buyer.model_dump(mode='json') | {'email': 'andere@example.invalid'})
self.load_sample()
self.window.generate()
self.assertIn('anderen Stammdaten', self.errors[0])
self.assertIsNone(self.window.worker)
self.assertFalse(self.window.root.exists())
def test_preview_creates_no_customer_or_invoice_number(self):
self.load_sample()
self.window.preview()
self.wait_worker()
self.assertFalse(self.errors)
self.assertGreater(self.window.document.pageCount(), 0)
self.assertIn('ENTWURF', self.window.preview_state.text())
self.assertFalse(self.window.root.exists())
self.assertEqual(list((self.window.presets / 'customers').glob('*.yaml')), [])
self.assertFalse(self.window.open_pdf.isEnabled())
def test_background_failure_restores_editing_without_success(self):
self.load_sample()
self.window.jar = self.home / 'kaputte-mustang.jar'
self.window.jar.write_text('Keine gültige JAR')
self.window.generate()
self.wait_worker()
self.assertEqual(len(self.errors), 1)
self.assertIn('Erstellung fehlgeschlagen', self.errors[0])
self.assertTrue(self.window.editor.isEnabled())
self.assertTrue(self.window.generate_button.isEnabled())
self.assertFalse(self.window.open_pdf.isEnabled())
self.assertIsNone(self.window.result)
def test_worker_start_failure_restores_form(self):
self.load_sample()
with patch('jr_einvoice.gui.sys.executable', '/nonexistent-jr-einvoice-python'):
self.window.preview()
self.wait_worker()
self.assertEqual(len(self.errors), 1)
self.assertIn('Hintergrundprozess fehlgeschlagen', self.errors[0])
self.assertTrue(self.window.editor.isEnabled())
self.assertTrue(self.window.preview_button.isEnabled())
@unittest.skipUnless(os.environ.get('JR_EINVOICE_INTEGRATION') == '1', 'Echte Mustang-Prüfung nur nach Opt-in')
def test_real_click_to_validated_pdf_preview_and_new_invoice(self):
self.load_sample()
self.window.generate_button.click()
self.assertFalse(self.window.editor.isEnabled())
self.wait_worker()
self.assertFalse(self.errors)
result = self.window.result
self.assertIsNotNone(result)
self.assertIn('status="valid"', result.build.validate_report.read_text())
self.assertGreater(self.window.document.pageCount(), 0)
self.assertEqual(result.attachment.read_bytes(), result.build.facturx_pdf.read_bytes())
self.assertFalse(self.window.generate_button.isEnabled())
self.assertFalse(self.window.preview_button.isEnabled())
self.assertTrue(self.window.open_pdf.isEnabled())
# Doppelaufruf desselben Formulars legt keine zusätzliche Nummer an.
self.window.generate()
self.assertIsNone(self.window.worker)
self.assertEqual(len(list(self.window.root.glob('*/invoice.yaml'))), 1)
self.window.notes.setPlainText('Geänderte Hinweise')
self.assertIn('vorherigen Stand', self.window.preview_state.text())
self.assertFalse(self.window.open_pdf.isEnabled())
self.window.new_invoice()
self.assertIsNone(self.window.result)
self.assertEqual(len(self.window.positions), 1)
self.assertEqual(self.window.document.pageCount(), 0)
self.assertFalse(self.window.dirty)