test_document_generation.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. """Render the supplied templates and reuse stored case documents through HTTP."""
  2. import io
  3. import tempfile
  4. import unittest
  5. from concurrent.futures import ThreadPoolExecutor
  6. from datetime import date
  7. from decimal import Decimal
  8. from pathlib import Path
  9. from types import MappingProxyType, SimpleNamespace
  10. from unittest.mock import MagicMock, patch
  11. from zipfile import ZipFile
  12. from xml.etree import ElementTree
  13. from fastapi import FastAPI
  14. from fastapi.testclient import TestClient
  15. from app.config import settings
  16. from app.db import get_session
  17. from app.document_generation import DOCX_MEDIA_TYPE, TEMPLATE_DIR
  18. from app.file_storage import get_or_create_file
  19. from app.routes import router
  20. class DocumentGenerationTests(unittest.TestCase):
  21. def setUp(self):
  22. self.temp = tempfile.TemporaryDirectory()
  23. self.addCleanup(self.temp.cleanup)
  24. self.root = Path(self.temp.name)
  25. config = patch.object(settings, 'LOCAL_STORAGE', self.temp.name)
  26. config.start()
  27. self.addCleanup(config.stop)
  28. self.db = MagicMock()
  29. self.db.execute.return_value.first.return_value = (1,)
  30. self.row = dict(
  31. Client_DB='1', Beleg_Nr='R1', Kunde_Nr='K1', Kunde_Name='Müller & <Partner>',
  32. Kunde_Adresse='Musterstraße 1', Kunde_PLZ='89073', Kunde_Ort='Ulm',
  33. Kunde_Email='kunde@example.com', Kunde_Telefon='012345',
  34. Beleg_Datum=date(2026, 11, 18), Beleg_Betrag=Decimal('550.48'), Beleg_Offen=Decimal('300.00'),
  35. Selbstbeteiligung_Betrag=Decimal('150.00'), Mwst_Betrag=Decimal('0'), Mwst_Betrag_berechnet=Decimal('104.59'),
  36. Versicherung_Typ='Vollkasko', Fahrzeug_Kennzeichen='UL-AB 123',
  37. Standort_Name='Ulm', Abteilung='SC', Mahnstufe=1,
  38. Verursacher_Text='Original', Verursacher_Aktuell_Text='Aktueller Verursacher',
  39. )
  40. self.db.execute.return_value.mappings.return_value.first.return_value = MappingProxyType(self.row)
  41. app = FastAPI()
  42. app.include_router(router)
  43. app.dependency_overrides[get_session] = lambda: self.db
  44. @app.middleware('http')
  45. async def authenticated(request, call_next):
  46. request.state.principal = SimpleNamespace(username='tester', name='Tester', csrf='csrf', role='Mitarbeiter')
  47. return await call_next(request)
  48. self.client = TestClient(app)
  49. self.addCleanup(self.client.close)
  50. self.url = '/app/forderungen/export/1_R1/'
  51. def test_each_supplied_template_renders_to_its_own_local_document(self):
  52. from app.document_generation import DocxTemplate
  53. for kind in ('Rechnung', 'Mahnung', 'Beitreibungsauftrag'):
  54. with self.subTest(kind=kind), patch('app.document_generation.DocxTemplate', wraps=DocxTemplate) as template:
  55. response = self.client.get(self.url, params={'document_type': kind})
  56. self.assertEqual(response.status_code, 200)
  57. self.assertEqual(response.headers['content-type'], DOCX_MEDIA_TYPE)
  58. template.assert_called_once_with(TEMPLATE_DIR / f'{kind}.docx')
  59. stored = self.root / '1_R1' / f'{kind}_1_R1.docx'
  60. self.assertEqual(stored.read_bytes(), response.content)
  61. with ZipFile(io.BytesIO(response.content)) as archive:
  62. document = ElementTree.fromstring(archive.read('word/document.xml'))
  63. text = ''.join(document.itertext())
  64. self.assertIn('Müller & <Partner>', text)
  65. self.assertIn('R1', text)
  66. self.assertNotIn('{{', text)
  67. if kind != 'Beitreibungsauftrag':
  68. self.assertIn('150,00', text)
  69. self.assertIn('0,00', text)
  70. else:
  71. self.assertIn('300,00', text)
  72. self.assertIn('Aktueller Verursacher', text)
  73. self.assertEqual(self.row['Beleg_Datum'], date(2026, 11, 18))
  74. def test_cached_documents_are_returned_unchanged_in_both_storage_layouts(self):
  75. for kind in ('Rechnung', 'Mahnung', 'Beitreibungsauftrag'):
  76. for legacy in (False, True):
  77. with self.subTest(kind=kind, legacy=legacy):
  78. tenant = '2' if legacy else '1'
  79. folder = self.root / tenant / 'R1' if legacy else self.root / f'{tenant}_R1'
  80. folder.mkdir(parents=True, exist_ok=True)
  81. stored = folder / f'{kind}_{tenant}_R1.docx'
  82. original = f'Already edited {kind}'.encode()
  83. stored.write_bytes(original)
  84. modified = stored.stat().st_mtime_ns
  85. self.db.reset_mock()
  86. with patch('app.document_generation.DocxTemplate') as template:
  87. response = self.client.get(f'/app/forderungen/export/{tenant}_R1/', params={'document_type': kind})
  88. self.assertEqual(response.content, original)
  89. self.assertEqual(stored.stat().st_mtime_ns, modified)
  90. template.assert_not_called()
  91. # Reuse still checks access but does not fetch/render new case data.
  92. self.db.execute.assert_called_once()
  93. def test_old_export_url_defaults_to_mahnung(self):
  94. response = self.client.get(self.url)
  95. self.assertEqual(response.status_code, 200)
  96. self.assertTrue((self.root / '1_R1/Mahnung_1_R1.docx').is_file())
  97. def test_unsupported_templates_and_unauthorized_cases_are_not_exported(self):
  98. response = self.client.get(self.url, params={'document_type': '../Rechnung'})
  99. self.assertEqual(response.status_code, 422)
  100. stored = self.root / '1_R1/Rechnung_1_R1.docx'
  101. stored.parent.mkdir()
  102. stored.write_bytes(b'cached')
  103. self.db.execute.return_value.first.return_value = None
  104. response = self.client.get(self.url, params={'document_type': 'Rechnung'})
  105. self.assertEqual(response.status_code, 404)
  106. self.assertEqual(stored.read_bytes(), b'cached')
  107. def test_missing_storage_or_template_has_a_useful_error(self):
  108. with patch.object(settings, 'LOCAL_STORAGE', None):
  109. self.assertEqual(self.client.get(self.url).status_code, 503)
  110. with patch('app.document_generation.TEMPLATE_DIR', self.root / 'missing-templates'):
  111. response = self.client.get(self.url)
  112. self.assertEqual(response.status_code, 503)
  113. self.assertIn('Mahnung.docx', response.json()['detail'])
  114. self.assertFalse((self.root / '1_R1/Mahnung_1_R1.docx').exists())
  115. def test_render_failure_does_not_publish_a_partial_document(self):
  116. with patch('app.document_generation.DocxTemplate') as template:
  117. template.return_value.render.side_effect = RuntimeError('broken template')
  118. with self.assertRaises(RuntimeError):
  119. self.client.get(self.url)
  120. self.assertFalse((self.root / '1_R1/Mahnung_1_R1.docx').exists())
  121. self.assertEqual(list((self.root / '.upload-staging').iterdir()), [])
  122. def test_concurrent_creation_reuses_one_result(self):
  123. created = []
  124. def create(path):
  125. created.append(path)
  126. path.write_bytes(b'rendered')
  127. def load(_):
  128. return get_or_create_file({'Client_DB': '1', 'Beleg_Nr': 'R1'}, 'Rechnung_1_R1.docx', create)
  129. with ThreadPoolExecutor(max_workers=3) as pool:
  130. paths = list(pool.map(load, range(3)))
  131. self.assertEqual(len(created), 1)
  132. self.assertEqual(len(set(paths)), 1)
  133. self.assertEqual(paths[0].read_bytes(), b'rendered')
  134. def test_document_paths_are_validated_before_creating_files(self):
  135. for number in ('../escape', 'a/b', 'CON'):
  136. with self.subTest(number=number), self.assertRaises(ValueError):
  137. get_or_create_file({'Client_DB': '1', 'Beleg_Nr': number}, 'Rechnung.docx', lambda path: None)
  138. def test_form_buttons_select_the_correct_template_and_allow_downloads(self):
  139. from html.parser import HTMLParser
  140. from urllib.parse import parse_qs, urlsplit
  141. from app.jinja_templates import templates
  142. class ExportLinks(HTMLParser):
  143. def __init__(self):
  144. super().__init__()
  145. self.links = []
  146. def handle_starttag(self, tag, attrs):
  147. attrs = dict(attrs)
  148. if tag == 'a' and '/export/' in attrs.get('href', ''):
  149. self.links.append(attrs)
  150. html = templates.env.get_template('forderungen/details/details_formular.html').render(
  151. forderungen_details=[{**self.row, 'Wiedervorlage': date(2026, 11, 18)}], versicherungen=[],
  152. request=SimpleNamespace(state=SimpleNamespace(principal=SimpleNamespace(csrf='csrf'))),
  153. return_url='/app/forderungen/liste', detail_url='/app/forderungen/details/1_R1', add7days='2026-11-25',
  154. )
  155. parser = ExportLinks()
  156. parser.feed(html)
  157. self.assertEqual(len(parser.links), 3)
  158. for link, kind in zip(parser.links, ('Rechnung', 'Mahnung', 'Beitreibungsauftrag')):
  159. self.assertEqual(link['hx-boost'], 'false')
  160. url = urlsplit(link['href'])
  161. self.assertEqual(url.path, self.url)
  162. self.assertEqual(parse_qs(url.query), {'document_type': [kind]})