Files
einvoice/tests/test_create.py

119 lines
6.6 KiB
Python

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')), [])