Files
einvoice/tests/test_invoices.py
T

122 lines
6.4 KiB
Python

import logging
import tempfile
import unittest
from datetime import date
from decimal import Decimal
from pathlib import Path
from unittest.mock import patch
from subprocess import CompletedProcess
import xml.etree.ElementTree as ET
from jr_einvoice.calc import compute_totals
from jr_einvoice.io import load_invoice_yaml
from jr_einvoice.models import Invoice
from jr_einvoice.facturx import generate_facturx_xml, NS
from jr_einvoice.mustang import validate, MustangError
from jr_einvoice.monthly import monthly_invoice
from jr_einvoice.render import render_html
from jr_einvoice.delivery import prepare_email
ROOT = Path(__file__).resolve().parent.parent
LOGGER = logging.getLogger('test')
class InvoicesTest(unittest.TestCase):
def setUp(self):
self.inv = load_invoice_yaml(ROOT / 'examples/invoice.sample.yaml')
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
def test_tax_rounding_by_group_and_line(self):
self.inv.items = [self.inv.items[0].model_copy(update={
'quantity': Decimal('1'), 'unit_price_net': Decimal('0.03')}) for _ in range(3)]
total = compute_totals(self.inv)
self.assertEqual(total.net_total, Decimal('0.09'))
self.assertEqual(total.tax_total, Decimal('0.02'))
self.inv.items[0].unit_price_net = Decimal('1.005')
self.assertEqual(compute_totals(self.inv).net_total, Decimal('1.07'))
def test_multiple_rates_and_complete_xml(self):
self.inv.items[1].tax_rate = Decimal('7')
total = compute_totals(self.inv)
self.assertEqual(total.gross_total, Decimal('1335.00'))
xml = generate_facturx_xml(self.inv, self.root / 'invoice.xml', LOGGER)
root = ET.parse(xml).getroot()
self.assertEqual(len(root.findall('.//ram:IncludedSupplyChainTradeLineItem', NS)), 2)
self.assertEqual(root.findtext('.//ram:SellerTradeParty/ram:Name', namespaces=NS), self.inv.seller.name)
self.assertEqual(root.findtext('.//ram:BuyerTradeParty/ram:Name', namespaces=NS), self.inv.buyer.name)
self.assertEqual(root.findtext('.//ram:GrandTotalAmount', namespaces=NS), '1335.00')
def test_invalid_inputs(self):
for change in [lambda d: d.update(items=[]),
lambda d: d['items'][0].update(tax_rate=0),
lambda d: d['items'][0].update(quantity='NaN'),
lambda d: d['items'][0].update(unit_price_net='Infinity'),
lambda d: d['meta'].update(unknown_field='typo'),
lambda d: d['meta'].update(service_period_start='2026-01-01'),
lambda d: d['payment'].update(iban='DE00000000000000000000'),
lambda d: d['payment'].update(due_date='2020-01-01'),
lambda d: d['seller'].update(email='a@example.com\nBcc: x@example.com')]:
data = self.inv.model_dump()
change(data)
with self.assertRaises(ValueError):
Invoice.model_validate(data)
def test_fail_closed_validator_retains_report(self):
for content, rc in [('', 0), ('noise', 0), ('<validation/>', 0),
('<validation><summary status="valid"/></validation>', 0),
('<validation><summary status="invalid"/></validation>', 0),
('<validation><summary status="valid"/></validation>', 1),
('<validation><pdf><summary status="invalid"/></pdf><summary status="valid"/></validation>', 0)]:
with patch('jr_einvoice.mustang._run', return_value=CompletedProcess([], rc, content, '')):
with self.assertRaises(MustangError):
validate(Path('fake.jar'), Path('fake.pdf'), self.root / 'report.xml', LOGGER)
self.assertTrue((self.root / 'report.xml').exists())
def test_validator_accepts_valid_summary(self):
content = '<validation><xml><summary status="valid"/></xml><summary status="valid"/></validation>'
with patch('jr_einvoice.mustang._run', return_value=CompletedProcess([], 0, content, '')):
validate(Path('fake.jar'), Path('fake.xml'), self.root / 'report.xml', LOGGER)
def test_display_preserves_price_and_quantity_precision(self):
self.inv.items[0].quantity = Decimal('1.125')
self.inv.items[0].unit_price_net = Decimal('0.125')
html = render_html(self.inv, ROOT / 'templates/invoice.html.j2')
self.assertIn('1,125', html)
self.assertIn('0,125 EUR', html)
self.assertIn('0,14 EUR', html)
def test_html_escapes_user_text(self):
self.inv.items[0].name = '<script>alert(1)</script>'
html = render_html(self.inv, ROOT / 'templates/invoice.html.j2')
self.assertNotIn('<script>', html)
self.assertIn('&lt;script&gt;', html)
def test_monthly_period_rate_and_duplicate_number(self):
# Explicit repository presets, no personal data required.
import yaml
repo = self.root / 'repo'
(repo / 'examples').mkdir(parents=True)
(repo / 'examples/invoice.init.yaml').write_text((ROOT / 'examples/invoice.sample.yaml').read_text())
(repo / 'presets/customers').mkdir(parents=True)
(repo / 'presets/customers/test.yaml').write_text(yaml.safe_dump({'items': [self.inv.items[0].model_dump(mode='json')]}))
target = monthly_invoice(self.root / 'invoices', repo, LOGGER, 'test', '2028-02', Decimal('75'), '2028-08', date(2028, 3, 1), Decimal('85'))
inv = load_invoice_yaml(target / 'invoice.yaml')
self.assertEqual(inv.meta.service_period_end, date(2028, 2, 29))
self.assertEqual(inv.payment.due_date, date(2028, 3, 31))
self.assertEqual(compute_totals(inv).gross_total, Decimal('7586.25'))
with self.assertRaises(FileExistsError):
monthly_invoice(self.root / 'invoices', repo, LOGGER, 'test', '2028-02', Decimal('75'), '2028-08')
def test_missing_recipient_cannot_leave_stale_draft(self):
import shutil
shutil.copy(ROOT / 'examples/invoice.sample.yaml', self.root / 'invoice.yaml')
(self.root / 'out').mkdir()
(self.root / 'out/invoice.eml').write_text('stale')
with self.assertRaises(ValueError):
prepare_email(self.root, Path('fake.jar'), LOGGER)
self.assertFalse((self.root / 'out/invoice.eml').exists())
if __name__ == '__main__':
unittest.main()