225 lines
11 KiB
Python
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)
|