test_dashboard.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. """Dashboard and exact drill-down contracts on the shared production SQL projection."""
  2. import json
  3. import re
  4. import unittest
  5. from collections import namedtuple
  6. from datetime import datetime
  7. from decimal import Decimal
  8. from unittest.mock import patch
  9. from urllib.parse import parse_qs, urlencode, urlsplit
  10. from app.dashboard_segment import pack_segment, parse_segment, segment_sql
  11. from app.forderung_dashboard import BERLIN, build_dashboard, color
  12. from app.navigation import list_return_url, normalize_page_url
  13. from tests import test_verursacher as fixture
  14. class DashboardTests(unittest.TestCase):
  15. def setUp(self):
  16. self.fixture = fixture.VerursacherTests()
  17. self.fixture.setUp()
  18. self.addCleanup(self.fixture.doCleanups)
  19. self.client, self.db = self.fixture.client, self.fixture.db
  20. from app.jinja_templates import templates
  21. sql = templates.env.get_template("forderungen/liste/queries/forderungen_liste.sql").render()
  22. needed = set(re.findall(r"(?:\[F\]|F)\.\[([^]]+)\]", sql))
  23. existing = {row[1] for row in self.db.connection.execute("PRAGMA dbo.table_info(Forderungen_Kopf)")}
  24. for name in needed - existing:
  25. kind = (
  26. "INTEGER"
  27. if name == "Beleg_Tage_offen"
  28. else "REAL" if name in {"Beleg_Betrag", "Kunde_offen_gesamt"} else "TEXT"
  29. )
  30. self.db.connection.execute(f"ALTER TABLE dbo.Forderungen_Kopf ADD [{name}] {kind}")
  31. self.db.connection.executescript("""
  32. ALTER TABLE dbo.Forderungen_Details ADD Rechtsanwalt TEXT;
  33. ALTER TABLE dbo.Forderungen_Details ADD Reklamation TEXT;
  34. ALTER TABLE dbo.Forderungen_Details ADD Mahnen_aussetzen TEXT;
  35. CREATE TABLE dbo.Forderungen_Kommentar_letzte (Client_DB TEXT, Beleg_Nr TEXT, Kommentar_Fibu TEXT, Kommentar_Abteilung TEXT, Begründung TEXT);
  36. CREATE TABLE Garantie2 (Client_DB TEXT, Beleg_Nr TEXT, Antrag_Art TEXT, Antrag_Nr TEXT, Antrag_Datum TEXT, Antrag_Benutzer_ID TEXT, Antrag_Status TEXT, Letzte_Beantragung TEXT);
  37. UPDATE dbo.Forderungen_Kopf SET Standort_Name='ULM', Hauptbetrieb_Name='AHR',
  38. Staffel_Text='> 12 Wochen', Beleg_Tage_offen=100, Rechnung_Gutschrift='Rechnung',
  39. Beleg_Faellig_Datum='2026-11-02', Beleg_Betrag=Beleg_Offen, Kunde_offen_gesamt=100;
  40. UPDATE dbo.Forderungen_Kopf SET Hauptbetrieb_ID='2', Hauptbetrieb_Name='AAM' WHERE Client_DB='2';
  41. UPDATE dbo.Forderungen_Kopf SET Staffel_Text='< 4 Wochen', Beleg_Tage_offen=10 WHERE Beleg_Nr='R1';
  42. INSERT INTO dbo.Forderungen_Details (Client_DB,Beleg_Nr,Verursacher_Benutzer_ID,Wiedervorlage) VALUES
  43. ('1','R1','alice','2026-11-01'), ('1','R3',NULL,'2026-11-02 13:30:00'),
  44. ('1','X1','bob','2026-11-09 13:30:00'), ('1','NV',NULL,'2026-11-10');
  45. UPDATE dbo.Benutzer_Rolle SET Name='Alice' WHERE Benutzer_ID='bob';
  46. INSERT INTO Garantie2 (Client_DB,Beleg_Nr,Antrag_Nr) VALUES ('1','R1','A'),('1','R1','B');
  47. INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','1','10','Service');
  48. INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','1','10','');
  49. """)
  50. columns = [row[1] for row in self.db.connection.execute("PRAGMA dbo.table_info(Forderungen_Kopf)")]
  51. select = ",".join(
  52. "'CR'" if name == "Beleg_Nr" else "-40" if name == "Beleg_Offen" else "[" + name + "]" for name in columns
  53. )
  54. self.db.connection.execute(
  55. "INSERT INTO dbo.Forderungen_Kopf SELECT "
  56. + select
  57. + " FROM dbo.Forderungen_Kopf WHERE Client_DB='1' AND Beleg_Nr='NV'"
  58. )
  59. self.db.connection.execute(
  60. "UPDATE dbo.Forderungen_Kopf SET Rechnung_Gutschrift='Gutschrift', Abteilung='', Staffel_Text=NULL, Verursacher_Benutzer_ID=NULL, Verursacher_Text=NULL, Beleg_Faellig_Datum=NULL WHERE Beleg_Nr='CR'"
  61. )
  62. self.db.connection.commit()
  63. execute = self.db.execute
  64. def dialect(query, values=None):
  65. query = str(query)
  66. for column in ["Kommentar_Fibu", "Kommentar_Abteilung"]:
  67. query = query.replace(
  68. f"left(isnull([FK].[{column}], ''), 50)", f"substr(ifnull([FK].[{column}], ''), 1, 50)"
  69. )
  70. query = (
  71. query.replace("isnull(", "ifnull(")
  72. .replace("dateadd(day, 4, getdate())", "'2026-11-06'")
  73. .replace("getdate()", "'2026-11-02'")
  74. )
  75. if query.strip().startswith("SELECT F.* FROM"):
  76. self.db.calls.append((query, values))
  77. cursor = self.db.connection.execute(query, values or {})
  78. columns = [item[0] for item in cursor.description]
  79. record = namedtuple("DashboardInvoice", columns)
  80. rows = []
  81. for raw in cursor.fetchall():
  82. row = dict(zip(columns, raw))
  83. for name in ["Beleg_Datum", "Beleg_Faellig_Datum", "Wiedervorlage"]:
  84. if row.get(name):
  85. row[name] = datetime.fromisoformat(row[name])
  86. rows.append(record(**row))
  87. class SQLRows:
  88. def keys(self):
  89. return columns
  90. def fetchall(self):
  91. return rows
  92. return SQLRows()
  93. return execute(query, values)
  94. adapter = patch.object(self.db, "execute", side_effect=dialect)
  95. adapter.start()
  96. self.addCleanup(adapter.stop)
  97. self.now = datetime(2026, 11, 2, 10, tzinfo=BERLIN)
  98. def data(self):
  99. return build_dashboard(self.db, "tester", self.now)
  100. def test_totals_rights_duplicate_roles_and_duplicate_join_rows(self):
  101. data = self.data()
  102. self.assertEqual(data["count"], 5)
  103. self.assertEqual(data["amount"], "200.00")
  104. self.assertEqual(data["amount_display"], "200,00")
  105. for groups in data["views"].values():
  106. self.assertEqual(sum(group["count"] for group in groups), 5)
  107. self.assertEqual(sum(Decimal(group["amount"]) for group in groups), Decimal(200))
  108. sql, values = self.db.calls[-1]
  109. self.assertIn("WHERE EXISTS", sql)
  110. self.assertEqual(values["auth_username"], "tester")
  111. self.assertTrue(all("AAM" not in group["label"] for group in data["views"]["Standort"]))
  112. def test_only_invoices_filters_all_aggregates_and_drilldowns(self):
  113. data = build_dashboard(self.db, "tester", self.now, only_invoices=True)
  114. self.assertEqual(data["count"], 4)
  115. self.assertEqual(data["amount"], "240.00")
  116. self.assertEqual(self.data()["count"], 5)
  117. for groups in data["views"].values():
  118. self.assertEqual(sum(group["count"] for group in groups), 4)
  119. for group in groups:
  120. for item in [group, *group["segments"]]:
  121. raw = parse_qs(urlsplit(item["url"]).query)["dashboard_segment"][0]
  122. self.assertTrue(parse_segment(raw)["only_invoices"])
  123. response = self.client.get(item["url"])
  124. self.assertIn('id="list-count">' + str(item["count"]), response.text)
  125. self.assertIn("Nur Rechnungen", response.text)
  126. self.db.connection.execute(
  127. "UPDATE dbo.Forderungen_Kopf SET Rechnung_Gutschrift='Rechnungskorrektur' WHERE Beleg_Nr='CR'"
  128. )
  129. self.db.connection.execute(
  130. "UPDATE dbo.Forderungen_Kopf SET Beleg_Offen=-100 WHERE Client_DB='1' AND Beleg_Nr='R1'"
  131. )
  132. negative = build_dashboard(self.db, "tester", self.now, only_invoices=True)
  133. self.assertEqual(negative["count"], 4)
  134. self.assertEqual(negative["amount"], "40.00")
  135. def test_invoice_checkbox_url_state_and_strict_segment_flag(self):
  136. response = self.client.get("/app/forderungen/dashboard?page=Verursacher&metric=anzahl&only_invoices=true")
  137. self.assertEqual(response.status_code, 200)
  138. self.assertIn('name="only_invoices" value="true" checked', response.text)
  139. self.assertIn('name="page" value="Verursacher"', response.text)
  140. self.assertIn('name="metric" value="anzahl"', response.text)
  141. self.assertIn("240,00", response.text)
  142. unchecked = self.client.get("/app/forderungen/dashboard")
  143. self.assertNotIn('name="only_invoices" value="true" checked', unchecked.text)
  144. self.assertIn("200,00", unchecked.text)
  145. self.assertIn('data-dashboard-view="Standort" data-dashboard-metric=', unchecked.text)
  146. for invalid in ["true", 1, None]:
  147. with self.assertRaises(ValueError):
  148. parse_segment(pack_segment({"view": "Altersstaffel", "key": None, "only_invoices": invalid}))
  149. def test_all_segments_round_trip_to_exact_list_count_and_amount(self):
  150. for view, groups in self.data()["views"].items():
  151. for group in groups:
  152. for segment in group["segments"]:
  153. with self.subTest(view=view, label=group["label"], department=segment["department"]):
  154. response = self.client.get(segment["url"])
  155. self.assertEqual(response.status_code, 200)
  156. count = int(re.search(r'id="list-count">(\d+)', response.text)[1])
  157. self.assertEqual(count, segment["count"])
  158. self.assertIn('name="dashboard_segment"', response.text)
  159. self.assertIn("Dashboardfilter entfernen", response.text)
  160. self.assertIn("Aktive Filter (1)", response.text)
  161. from app.jinja_templates import number_format
  162. self.assertIn(number_format(Decimal(segment["amount"])) + " €", response.text)
  163. def test_follow_up_boundaries_and_null_group(self):
  164. cases = [
  165. ("2026-11-01", "overdue"),
  166. ("2026-11-02", "soon"),
  167. ("2026-11-05 23:30:00", "soon"),
  168. ("2026-11-06", "not_due"),
  169. ("2026-11-16 23:30:00", "not_due"),
  170. ("2026-11-17", "deferred"),
  171. ]
  172. for value, expected in cases:
  173. with self.subTest(date=value):
  174. self.db.connection.execute(
  175. "UPDATE dbo.Forderungen_Details SET Wiedervorlage=? WHERE Beleg_Nr='R1'", (value,)
  176. )
  177. data = build_dashboard(self.db, "tester", self.now, stack="wiedervorlage")
  178. group = next(group for group in data["views"]["Altersstaffel"] if group["label"] == "< 4 Wochen")
  179. self.assertEqual(group["segments"][0]["id"], expected)
  180. response = self.client.get(group["segments"][0]["url"])
  181. self.assertIn('id="list-count">1', response.text)
  182. ids = [item["id"] for item in data["stack_items"]]
  183. self.assertEqual(ids, ["overdue", "soon", "not_due", "deferred", "undated"])
  184. self.assertNotIn("Wiedervorlage", data["views"])
  185. def test_follow_up_falls_back_to_document_due_date(self):
  186. self.db.connection.execute("UPDATE dbo.Forderungen_Details SET Wiedervorlage=NULL WHERE Beleg_Nr='R1'")
  187. self.db.connection.execute(
  188. "UPDATE dbo.Forderungen_Kopf SET Beleg_Faellig_Datum='2026-11-17' WHERE Client_DB='1' AND Beleg_Nr='R1'"
  189. )
  190. data = build_dashboard(self.db, "tester", self.now, stack="wiedervorlage")
  191. group = next(group for group in data["views"]["Altersstaffel"] if group["label"] == "< 4 Wochen")
  192. self.assertEqual(group["segments"][0]["id"], "deferred")
  193. self.assertIn('id="list-count">1', self.client.get(group["segments"][0]["url"]).text)
  194. def test_follow_up_year_boundary_and_berlin_calendar_day(self):
  195. self.db.connection.execute("UPDATE dbo.Forderungen_Details SET Wiedervorlage='2027-01-01' WHERE Beleg_Nr='R1'")
  196. now = datetime.fromisoformat("2026-12-28T23:30:00+00:00")
  197. groups = build_dashboard(self.db, "tester", now, stack="wiedervorlage")["views"]["Altersstaffel"]
  198. group = next(group for group in groups if group["label"] == "< 4 Wochen")
  199. selection = parse_segment(parse_qs(urlsplit(group["segments"][0]["url"]).query)["dashboard_segment"][0])
  200. self.assertEqual(selection["follow_up"], {"from": "2026-12-29", "to": "2027-01-01"})
  201. self.assertEqual(group["segments"][0]["id"], "soon")
  202. def test_follow_up_stack_roundtrips_for_all_rubrics_and_invoice_scope(self):
  203. for only in [False, True]:
  204. data = build_dashboard(self.db, "tester", self.now, only_invoices=only, stack="wiedervorlage")
  205. for groups in data["views"].values():
  206. self.assertEqual(sum(group["count"] for group in groups), data["count"])
  207. self.assertEqual(sum(Decimal(group["amount"]) for group in groups), Decimal(data["amount"]))
  208. for group in groups:
  209. for segment in group["segments"]:
  210. selection = parse_segment(parse_qs(urlsplit(segment["url"]).query)["dashboard_segment"][0])
  211. self.assertIn("follow_up", selection)
  212. self.assertNotIn("department", selection)
  213. response = self.client.get(segment["url"])
  214. self.assertEqual(response.status_code, 200)
  215. self.assertIn('id="list-count">' + str(segment["count"]), response.text)
  216. def test_stack_selection_and_legacy_follow_up_links(self):
  217. response = self.client.get("/app/forderungen/dashboard?stack=wiedervorlage&metric=anzahl&only_invoices=true")
  218. self.assertEqual(response.status_code, 200)
  219. self.assertNotIn('data-dashboard-view="Wiedervorlage"', response.text)
  220. self.assertIn('value="wiedervorlage" selected', response.text)
  221. self.assertIn('name="stack" value="wiedervorlage"', response.text)
  222. self.assertEqual(self.client.get("/app/forderungen/dashboard?stack=bad").status_code, 422)
  223. old = self.client.get(
  224. "/app/forderungen/dashboard?page=Wiedervorlage&metric=anzahl&only_invoices=true", follow_redirects=False
  225. )
  226. self.assertEqual(old.status_code, 303)
  227. params = parse_qs(urlsplit(old.headers["location"]).query)
  228. self.assertEqual(
  229. params, {"page": ["Standort"], "metric": ["anzahl"], "stack": ["wiedervorlage"], "only_invoices": ["true"]}
  230. )
  231. legacy = pack_segment({"view": "Wiedervorlage", "key": {"from": "2026-11-02", "to": "2026-11-09"}})
  232. response = self.client.get("/app/forderungen/liste", params={"dashboard_segment": legacy})
  233. self.assertIn('id="list-count">2', response.text)
  234. for bad in [True, "tomorrow", {"from": "2026-11-02", "to": "2026-11-01"}]:
  235. with self.assertRaises(ValueError):
  236. parse_segment(pack_segment({"view": "Altersstaffel", "key": None, "follow_up": bad}))
  237. def test_same_names_different_user_ids_and_clients_remain_separate(self):
  238. groups = [group for group in self.data()["views"]["Verursacher"] if group["label"].startswith("Alice (")]
  239. self.assertEqual(len(groups), 2)
  240. self.assertNotEqual(groups[0]["label"], groups[1]["label"])
  241. self.assertNotEqual(groups[0]["url"], groups[1]["url"])
  242. self.db.connection.execute("INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','2','10','Service')")
  243. groups = self.data()["views"]["Standort"]
  244. self.assertEqual(len(groups), 2)
  245. for group in groups:
  246. self.assertEqual(self.client.get(group["url"]).status_code, 200)
  247. self.assertEqual({group["label"] for group in groups}, {"ULM (AHR)", "ULM (AAM)"})
  248. def test_decimal_sum_and_zero_amount_segments_still_have_links(self):
  249. self.db.connection.execute(
  250. "UPDATE dbo.Forderungen_Kopf SET Beleg_Offen=0.1 WHERE Client_DB='1' AND Beleg_Nr='R1'"
  251. )
  252. self.db.connection.execute(
  253. "UPDATE dbo.Forderungen_Kopf SET Beleg_Offen=0.2 WHERE Client_DB='1' AND Beleg_Nr='X1'"
  254. )
  255. data = self.data()
  256. self.assertEqual(data["amount"], "10.30")
  257. zero = build_dashboard(self.db, "tester", self.now, stack="wiedervorlage")
  258. self.assertTrue(
  259. any(
  260. segment["amount"] == "0.00" and segment["url"]
  261. for group in zero["views"]["Verursacher"]
  262. for segment in group["segments"]
  263. )
  264. )
  265. self.assertEqual(color("Service"), color("Service"))
  266. def test_exact_department_filter_does_not_match_prefixes(self):
  267. self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Abteilung='Service2' WHERE Beleg_Nr='CR'")
  268. self.db.connection.execute("INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','1','10','Service2')")
  269. group = self.data()["views"]["Standort"][0]
  270. segment = next(segment for segment in group["segments"] if segment["department"] == "Service")
  271. exact = self.client.get(segment["url"])
  272. partial = self.client.get("/app/forderungen/liste?Abteilung=Service")
  273. self.assertIn('id="list-count">4', exact.text)
  274. self.assertIn('id="list-count">5', partial.text)
  275. def test_forged_segment_cannot_expand_invoice_access(self):
  276. raw = pack_segment({"view": "Standort", "key": {"client": "2", "site": "10"}})
  277. response = self.client.get("/app/forderungen/liste", params={"dashboard_segment": raw})
  278. self.assertEqual(response.status_code, 200)
  279. self.assertIn('id="list-count">0', response.text)
  280. self.assertIn("0,00 €", response.text)
  281. def test_segment_preserved_through_detail_return_login_and_normal_filters(self):
  282. url = self.data()["views"]["Standort"][0]["url"]
  283. selection = parse_qs(urlsplit(url).query)["dashboard_segment"][0]
  284. returned = list_return_url(url, "1_R1")
  285. self.assertEqual(parse_qs(urlsplit(returned).query)["dashboard_segment"], [selection])
  286. detail = "/app/forderungen/details/1_R1?" + urlencode({"return_to": returned})
  287. recovered = normalize_page_url(
  288. "/app/forderungen/details/1_R1/kommentare", current_url="https://proxy.test" + detail
  289. )
  290. self.assertEqual(recovered, detail)
  291. response = self.client.get(url + "&Kunde=Test&page=1&limit=1")
  292. self.assertEqual(response.status_code, 200)
  293. self.assertIn('hx-include="#liste-filter"', response.text)
  294. sql, values = self.db.calls[-1]
  295. self.assertIn("F.[Client_DB] = :ds_client", sql)
  296. self.assertEqual(values["filter_kunde"], "%Test%")
  297. self.client.cookies.clear()
  298. response = self.client.get(url, headers={"HX-Request": "true"})
  299. target = parse_qs(urlsplit(response.headers["HX-Redirect"]).query)["next"][0]
  300. self.assertEqual(target, url)
  301. def test_bad_segments_are_rejected_before_data_query(self):
  302. invalid = [
  303. "not-json",
  304. "{}",
  305. '{"view":"Standort","key":{}}',
  306. '{"view":"Wiedervorlage","key":{"from":"x","to":null}}',
  307. '{"view":"Altersstaffel","key":null,"sql":"1=1"}',
  308. '{"view":"Verursacher","key":{"id":null}}',
  309. '{"view":"Standort","key":{"client":true,"site":"10"}}',
  310. ]
  311. for value in invalid:
  312. with self.subTest(value=value):
  313. self.db.calls.clear()
  314. response = self.client.get("/app/forderungen/liste", params={"dashboard_segment": value})
  315. self.assertEqual(response.status_code, 400)
  316. self.assertFalse(self.db.calls)
  317. def test_empty_error_and_invalid_dashboard_states(self):
  318. response = self.client.get("/app/forderungen/dashboard?page=Standort&metric=anzahl")
  319. self.assertEqual(response.status_code, 200)
  320. self.assertIn("Forderungen | Dashboard", response.text)
  321. self.assertIn('data-dashboard-metric="anzahl"', response.text)
  322. self.assertEqual(self.client.get("/app/forderungen/dashboard?page=bad").status_code, 422)
  323. self.db.connection.execute("DELETE FROM dbo.Benutzer_Rechte WHERE Benutzer_ID='tester'")
  324. data = self.data()
  325. self.assertEqual(data["count"], 0)
  326. self.assertEqual(data["amount"], "0.00")
  327. with (
  328. patch("app.forderung_dashboard.read_invoices", side_effect=RuntimeError("offline")),
  329. self.assertLogs("app.routes", level="ERROR"),
  330. ):
  331. response = self.client.get("/app/forderungen/dashboard")
  332. self.assertEqual(response.status_code, 503)
  333. self.assertIn("nicht geladen", response.text)
  334. self.assertNotIn("Anzahl der Belege", response.text)
  335. if __name__ == "__main__":
  336. unittest.main()