test_forderung_list.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. """List response contracts with an isolated query result (no SQL Server needed)."""
  2. import unittest
  3. from collections import namedtuple
  4. from datetime import date
  5. from html.parser import HTMLParser
  6. from types import SimpleNamespace
  7. from fastapi import FastAPI
  8. from fastapi.testclient import TestClient
  9. from app.db import get_session
  10. from app.routes import router
  11. def invoice(number=0, **changes):
  12. data = dict(
  13. Client_DB="1",
  14. Hauptbetrieb_Name="AHR",
  15. Standort_ID="10",
  16. Standort_Name="ULM",
  17. Abteilung="SC",
  18. Verursacher_Text="dki",
  19. Rechnung_Gutschrift="Rechnung",
  20. Fahrzeug_Nr="V1",
  21. Kunde_Text="Musterkunde",
  22. Beleg_Nr=f"R{number}",
  23. Beleg_Datum=date(2026, 10, 1),
  24. Beleg_Faellig_Datum=date(2026, 10, 15),
  25. Staffel_Text="> 12 Wochen",
  26. Mahnstufe="1",
  27. Bearbeitet="N",
  28. Abwarten="",
  29. Wiedervorlage=date(2026, 11, 1),
  30. Wiedervorlage_Stufe="0",
  31. Beleg_Betrag=20,
  32. Beleg_Offen=10,
  33. Kunde_offen_gesamt=50,
  34. Kunde_gesamt_Bezug="",
  35. Besitzer_Kunde_Text="",
  36. Kunde_Email="kunde@example.com",
  37. Beleg_Tage_offen=28,
  38. Kommentar_Fibu="Fibu Kommentar",
  39. Kommentar_Abteilung="Abteilung Kommentar",
  40. Antrag_Nr="",
  41. Antrag_Art="",
  42. Antrag_Datum=None,
  43. Antrag_Benutzer_ID="",
  44. Antrag_Status="",
  45. )
  46. data.update(changes)
  47. return namedtuple("Invoice", data)(**data)
  48. class QueryResult:
  49. def __init__(self, rows):
  50. self.rows = rows
  51. def keys(self):
  52. return invoice()._fields
  53. def fetchall(self):
  54. return self.rows
  55. class ListDB:
  56. def __init__(self):
  57. self.rows = []
  58. self.calls = []
  59. def execute(self, query, values):
  60. self.calls.append((str(query), values))
  61. return QueryResult(self.rows)
  62. class Tags(HTMLParser):
  63. def __init__(self, html):
  64. super().__init__()
  65. self.tags = []
  66. self.feed(html)
  67. def handle_starttag(self, tag, attrs):
  68. self.tags.append((tag, dict(attrs)))
  69. class ForderungListTests(unittest.TestCase):
  70. def setUp(self):
  71. self.db = ListDB()
  72. self.application = FastAPI()
  73. self.application.include_router(router)
  74. self.application.dependency_overrides[get_session] = lambda: self.db
  75. @self.application.middleware("http")
  76. async def fixture_principal(request, call_next):
  77. request.state.principal = SimpleNamespace(username="tester", name="Tester", role="Buchhaltung", csrf="csrf")
  78. return await call_next(request)
  79. self.client = TestClient(self.application)
  80. self.addCleanup(self.client.close)
  81. def test_empty_first_page_retains_all_filters_and_zero_summary(self):
  82. response = self.client.get("/app/forderungen/liste")
  83. self.assertEqual(response.status_code, 200)
  84. self.assertIn("Keine Forderungen", response.text)
  85. self.assertIn("0,00 €", response.text)
  86. tags = Tags(response.text).tags
  87. fields = [a for t, a in tags if t in ("input", "select") and a.get("id")]
  88. self.assertEqual(len(fields), 16)
  89. self.assertIn('id="list-count">0', response.text)
  90. def test_empty_results_preserve_selected_filter_and_query_parameters(self):
  91. response = self.client.get("/app/forderungen/liste?Filiale=10&Kunde=Nichtgefunden")
  92. self.assertEqual(response.status_code, 200)
  93. self.assertIn('<option value="10" selected>10</option>', response.text)
  94. self.assertIn('value="Nichtgefunden"', response.text)
  95. sql, params = self.db.calls[-1]
  96. self.assertEqual(params["auth_username"], "tester")
  97. self.assertEqual(params["filter_filiale"], "%10%")
  98. self.assertEqual(params["filter_kunde"], "%Nichtgefunden%")
  99. self.assertIn("F.[Standort_ID] LIKE :filter_filiale", sql)
  100. def test_summary_uses_all_matches_and_paging_preserves_filter_form(self):
  101. self.db.rows = [invoice(n) for n in range(103)]
  102. response = self.client.get("/app/forderungen/liste?Filiale=10")
  103. self.assertIn('id="list-count">103', response.text)
  104. self.assertIn("1.030,00 €", response.text)
  105. tags = Tags(response.text).tags
  106. rows = [a for t, a in tags if t == "tr" and a.get("class") == "gc-invoice-row"]
  107. self.assertEqual(len(rows), 100)
  108. loader = next(a for t, a in tags if a.get("class") == "gc-list-load")
  109. self.assertEqual(loader["hx-include"], "#liste-filter")
  110. self.assertIn('"page": 1', loader["hx-vals"])
  111. def test_last_page_is_a_fragment_and_exhausted_page_is_empty_html(self):
  112. self.db.rows = [invoice(n) for n in range(103)]
  113. response = self.client.get("/app/forderungen/liste?page=1&Filiale=10")
  114. self.assertEqual(response.status_code, 200)
  115. self.assertNotIn("<html", response.text)
  116. self.assertNotIn("gc-list-load", response.text)
  117. self.assertEqual(response.text.count('class="gc-invoice-row"'), 3)
  118. self.assertIn("R100", response.text)
  119. exhausted = self.client.get("/app/forderungen/liste?page=2&Filiale=10")
  120. self.assertEqual(exhausted.status_code, 200)
  121. self.assertEqual(exhausted.text, "")
  122. self.assertTrue(exhausted.headers["content-type"].startswith("text/html"))
  123. def test_comments_are_escaped_and_unique_across_tenants(self):
  124. self.db.rows = [invoice(1, Kommentar_Fibu="<script>unsafe</script>"), invoice(1, Client_DB="2")]
  125. response = self.client.get("/app/forderungen/liste")
  126. self.assertIn("&lt;script&gt;unsafe&lt;/script&gt;", response.text)
  127. tags = Tags(response.text).tags
  128. controls = [a["aria-controls"] for t, a in tags if "data-gc-comments" in a]
  129. self.assertEqual(len(set(controls)), 2)
  130. for target in controls:
  131. self.assertTrue(any(t == "tr" and a.get("id") == target and "hidden" in a for t, a in tags))
  132. def test_application_information_precedes_department_comment(self):
  133. self.db.rows = [
  134. invoice(
  135. 1,
  136. Antrag_Nr="A42",
  137. Antrag_Art="Antrag",
  138. Antrag_Datum=date(2026, 10, 1),
  139. Antrag_Benutzer_ID="dki",
  140. Antrag_Status="Offen",
  141. ),
  142. invoice(2, Kommentar_Fibu="", Kommentar_Abteilung=""),
  143. ]
  144. response = self.client.get("/app/forderungen/liste")
  145. self.assertIn("Antrag A42", response.text)
  146. self.assertNotIn("Abteilung Kommentar", response.text)
  147. self.assertEqual(response.text.count("data-gc-comments"), 1)
  148. def test_return_focus_loads_through_target_and_continues_at_next_block(self):
  149. self.db.rows = [invoice(n) for n in range(305)]
  150. response = self.client.get("/app/forderungen/liste?Filiale=10&focus=1_R220")
  151. self.assertEqual(response.status_code, 200)
  152. self.assertEqual(response.text.count('class="gc-invoice-row"'), 300)
  153. self.assertIn('data-primary-key="1_R220"', response.text)
  154. self.assertIn('"page": 3', response.text)
  155. self.assertIn('id="list-count">305', response.text)
  156. self.assertEqual(response.headers["cache-control"], "no-store")
  157. sql, parameters = self.db.calls[-1]
  158. self.assertNotIn("focus", parameters)
  159. self.assertNotIn("focus", sql.lower())
  160. def test_focus_is_found_at_new_position_and_within_exact_tenant(self):
  161. self.db.rows = [invoice(220, Client_DB="2")] + [invoice(n) for n in range(150)] + [invoice(220)]
  162. response = self.client.get("/app/forderungen/liste?Filiale=10&focus=1_R220")
  163. self.assertEqual(response.text.count('class="gc-invoice-row"'), 152)
  164. self.assertNotIn("gc-return-status", response.text)
  165. self.assertIn('data-focus="1_R220"', response.text)
  166. def test_missing_focus_preserves_filters_and_explains_missing_row(self):
  167. response = self.client.get("/app/forderungen/liste?Filiale=10&focus=1_R404")
  168. self.assertIn("nicht mehr enthalten", response.text)
  169. self.assertIn('<option value="10" selected>10</option>', response.text)
  170. self.assertIn("Keine Forderungen", response.text)
  171. def test_detail_links_keep_filters_including_on_loaded_fragments(self):
  172. from urllib.parse import parse_qs, urlsplit
  173. self.db.rows = [invoice(n) for n in range(103)]
  174. for url, key in [
  175. ("/app/forderungen/liste?Filiale=10&Kunde=Muster", "1_R0"),
  176. ("/app/forderungen/liste?page=1&Filiale=10&Kunde=Muster", "1_R100"),
  177. ]:
  178. response = self.client.get(url)
  179. link = next(a for t, a in Tags(response.text).tags if "data-gc-detail-link" in a)
  180. return_to = parse_qs(urlsplit(link["href"]).query)["return_to"][0]
  181. self.assertEqual(
  182. parse_qs(urlsplit(return_to).query), {"Filiale": ["10"], "Kunde": ["Muster"], "focus": [key]}
  183. )
  184. if __name__ == "__main__":
  185. unittest.main()