import io import logging import os from datetime import date from decimal import Decimal from pathlib import Path import tempfile import unittest from unittest.mock import patch import yaml from jr_einvoice.create import decimal_input, save_customer, find_customer, service_period, create_invoice from jr_einvoice.quick_cli import main from jr_einvoice.pipeline import BuildResult from jr_einvoice.presets import load_yaml ROOT = Path(__file__).resolve().parent.parent class CreateTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) self.presets = self.root / 'presets' self.presets.mkdir() sample = load_yaml(ROOT / 'examples/invoice.sample.yaml') (self.presets / 'seller.default.yaml').write_text(yaml.safe_dump({ 'seller': sample['seller'], 'payment': sample['payment']})) self.key, _ = save_customer(self.presets, 'Neuer / alter Kunde GmbH', sample['buyer'] | {'name': 'Neuer / alter Kunde GmbH'}) self.jar = self.root / 'test.jar' self.jar.touch() self.kwargs = dict(root=self.root / 'invoices', resources=ROOT, presets=self.presets, customer='Neuer / alter Kunde GmbH', entry='Geleistete Stunden Oktober 2026', volume=Decimal('120'), price=Decimal('85'), jar=self.jar, issued=date(2026, 10, 31), logger=logging.getLogger('test')) def fake_build(self, target, *args): out = target / 'out' (out / 'validation').mkdir(parents=True, exist_ok=True) paths = [out / 'invoice.pdf', out / 'factur-x.xml', out / 'invoice.facturx.pdf', out / 'validation/report.xml'] for path in paths: path.write_bytes(b'test artifact') return BuildResult(target, out, *paths) def test_german_numbers(self): for text, expected in [('85,00 €', '85'), ('1.234,56 EUR', '1234.56'), ('120', '120'), ('72,5', '72.5'), ('85.00', '85'), ('0.125', '0.125')]: self.assertEqual(decimal_input(text), Decimal(expected)) for text in ['NaN', 'Infinity', '1,2,3', '12.34,56', '85 USD', '', '1e9']: with self.assertRaises(ValueError): decimal_input(text) def test_customer_reuse_and_no_overwrite(self): self.assertEqual(find_customer(self.presets, 'NEUER / ALTER KUNDE GMBH')[0], self.key) self.assertEqual(find_customer(self.presets, self.key)[0], self.key) with self.assertRaises(FileExistsError): save_customer(self.presets, 'Neuer / alter Kunde GmbH', find_customer(self.presets, self.key)[1]['buyer']) self.assertEqual((self.presets / 'customers' / f'{self.key}.yaml').stat().st_mode & 0o777, 0o600) def test_service_period_inference_and_override(self): self.assertEqual(service_period('Geleistete Stunden Februar 2028', None, None, date(2028, 3, 1)), (date(2028, 2, 29), date(2028, 2, 1), date(2028, 2, 29))) self.assertEqual(service_period('Oktober 2026', None, date(2026, 10, 15), date.today()), (date(2026, 10, 15), None, None)) with self.assertRaises(ValueError): service_period('Test', '2026-13', None, date.today()) with self.assertRaises(ValueError): service_period('Test', '2026-10', date.today(), date.today()) def test_create_allocates_numbers_and_attachment(self): with patch('jr_einvoice.create.build_in_folder', side_effect=self.fake_build): first = create_invoice(**self.kwargs) second = create_invoice(**self.kwargs) with self.assertRaises(ValueError): create_invoice(**self.kwargs, number=first.invoice.meta.invoice_number) self.assertEqual(first.invoice.meta.invoice_number, 'JR-2026-0001') self.assertEqual(second.invoice.meta.invoice_number, 'JR-2026-0002') self.assertEqual(first.invoice.meta.service_period_end, date(2026, 10, 31)) self.assertEqual(first.invoice.payment.due_date, date(2026, 11, 30)) self.assertEqual(first.attachment.read_bytes(), first.build.facturx_pdf.read_bytes()) self.assertEqual(first.build.invoice_dir.stat().st_mode & 0o777, 0o700) self.assertTrue((first.build.out_dir / 'manifest.json').is_file()) def test_failed_build_retains_number_but_publishes_no_attachment(self): with patch('jr_einvoice.create.build_in_folder', side_effect=RuntimeError('invalid XML')): with self.assertRaises(RuntimeError): create_invoice(**self.kwargs) self.assertEqual(len(list((self.root / 'invoices').glob('*/invoice.yaml'))), 1) self.assertEqual(list((self.root / 'invoices').glob('*/Rechnung_*.pdf')), []) with patch('jr_einvoice.create.build_in_folder', side_effect=self.fake_build): second = create_invoice(**self.kwargs) self.assertEqual(second.invoice.meta.invoice_number, 'JR-2026-0002') def test_unknown_customer_noninteractive_is_actionable(self): with patch.dict(os.environ, {'JR_EINVOICE_PRESETS': str(self.presets)}), patch('sys.stderr', new_callable=io.StringIO) as err: with self.assertRaises(SystemExit) as result: main(['--create', '--Customer', 'Unbekannt', '--Entry', 'Beratung', '--Volume', '1', '--Price', '85,00 €', '--non-interactive']) self.assertEqual(result.exception.code, 1) self.assertIn('--Street', err.getvalue()) self.assertIsNone(find_customer(self.presets, 'Unbekannt')) def test_interactive_customer_is_saved_once(self): with patch.dict(os.environ, {'JR_EINVOICE_PRESETS': str(self.presets)}), \ patch('sys.stdin.isatty', return_value=True), \ patch('builtins.input', side_effect=['Neuer Weg 5', '12345', 'Berlin']) as ask, \ patch('jr_einvoice.quick_cli.create_invoice', side_effect=RuntimeError('stop before build')), \ patch('sys.stderr', new_callable=io.StringIO), patch('sys.stdout', new_callable=io.StringIO): with self.assertRaises(SystemExit): main(['--create', '--Customer', 'Neue Firma', '--Entry', 'Beratung', '--Volume', '1', '--Price', '85']) self.assertEqual(ask.call_count, 3) self.assertEqual(find_customer(self.presets, 'Neue Firma')[1]['buyer']['address']['street'], 'Neuer Weg 5') def test_invalid_amount_creates_no_invoice(self): with self.assertRaises(ValueError): create_invoice(**(self.kwargs | {'volume': Decimal('-1')})) self.assertEqual(list((self.root / 'invoices').glob('*/invoice.yaml')), [])