test_dashboard.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  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
  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. from tests.test_forderung_list import invoice
  15. from app.query_filter import decode_filter_values
  16. class DashboardTests(unittest.TestCase):
  17. def setUp(self):
  18. self.fixture = fixture.VerursacherTests()
  19. self.fixture.setUp()
  20. self.addCleanup(self.fixture.doCleanups)
  21. self.client, self.db = self.fixture.client, self.fixture.db
  22. from app.jinja_templates import templates
  23. sql = templates.env.get_template("forderungen/liste/queries/forderungen_liste.sql").render()
  24. needed = set(invoice()._fields) | set(re.findall(r"(?:\[F\]|F)\.\[([^]]+)\]", sql))
  25. existing = {row[1] for row in self.db.connection.execute("PRAGMA dbo.table_info(Forderungen_Kopf)")}
  26. for name in needed - existing:
  27. kind = (
  28. "INTEGER"
  29. if name in {"Beleg_Tage_offen", "Wiedervorlage_Staffel_Nr"}
  30. else "REAL" if name in {"Beleg_Betrag", "Kunde_offen_gesamt"} else "TEXT"
  31. )
  32. self.db.connection.execute(f"ALTER TABLE dbo.Forderungen_Kopf ADD [{name}] {kind}")
  33. self.db.connection.executescript("""
  34. ALTER TABLE dbo.Forderungen_Details ADD Rechtsanwalt TEXT;
  35. ALTER TABLE dbo.Forderungen_Details ADD Reklamation TEXT;
  36. ALTER TABLE dbo.Forderungen_Details ADD Mahnen_aussetzen TEXT;
  37. CREATE TABLE dbo.Forderungen_Kommentar_letzte (Client_DB TEXT, Beleg_Nr TEXT, Kommentar_Fibu TEXT, Kommentar_Abteilung TEXT, Begründung TEXT);
  38. CREATE TABLE dbo.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);
  39. UPDATE dbo.Forderungen_Kopf SET Standort_Name='ULM', Hauptbetrieb_Name='AHR',
  40. Staffel_Text='> 12 Wochen', Beleg_Tage_offen=100, Rechnung_Gutschrift='Rechnung',
  41. Beleg_Faellig_Datum='2026-11-02', Beleg_Betrag=Beleg_Offen, Kunde_offen_gesamt=100;
  42. UPDATE dbo.Forderungen_Kopf SET Hauptbetrieb_ID='2', Hauptbetrieb_Name='AAM' WHERE Client_DB='2';
  43. UPDATE dbo.Forderungen_Kopf SET Staffel_Text='< 4 Wochen', Beleg_Tage_offen=10 WHERE Beleg_Nr='R1';
  44. INSERT INTO dbo.Forderungen_Details (Client_DB,Beleg_Nr,Verursacher_Benutzer_ID,Wiedervorlage) VALUES
  45. ('1','R1','alice','2026-11-01'), ('1','R3',NULL,'2026-11-02 13:30:00'),
  46. ('1','X1','bob','2026-11-09 13:30:00'), ('1','NV',NULL,'2026-11-10');
  47. UPDATE dbo.Benutzer_Rolle SET Name='Alice' WHERE Benutzer_ID='bob';
  48. INSERT INTO Garantie2 (Client_DB,Beleg_Nr,Antrag_Nr) VALUES ('1','R1','A'),('1','R1','B');
  49. INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','1','10','Service');
  50. INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','1','10','');
  51. """)
  52. columns = [row[1] for row in self.db.connection.execute("PRAGMA dbo.table_info(Forderungen_Kopf)")]
  53. select = ",".join(
  54. "'CR'" if name == "Beleg_Nr" else "-40" if name == "Beleg_Offen" else "[" + name + "]" for name in columns
  55. )
  56. self.db.connection.execute(
  57. "INSERT INTO dbo.Forderungen_Kopf SELECT "
  58. + select
  59. + " FROM dbo.Forderungen_Kopf WHERE Client_DB='1' AND Beleg_Nr='NV'"
  60. )
  61. self.db.connection.execute(
  62. "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'"
  63. )
  64. # Model source-owned classifications independently of the dashboard clock.
  65. self.db.connection.executescript("""
  66. UPDATE dbo.Forderungen_Kopf SET Wiedervorlage_Staffel_Nr=2, Wiedervorlage_Staffel_Text='In 3 Tagen fällig';
  67. UPDATE dbo.Forderungen_Kopf SET Wiedervorlage_Staffel_Nr=1, Wiedervorlage_Staffel_Text='Überfällig' WHERE Beleg_Nr='R1';
  68. UPDATE dbo.Forderungen_Kopf SET Wiedervorlage_Staffel_Nr=3, Wiedervorlage_Staffel_Text='Noch nicht fällig' WHERE Beleg_Nr IN ('X1','NV');
  69. UPDATE dbo.Forderungen_Kopf SET Wiedervorlage_Staffel_Nr=NULL, Wiedervorlage_Staffel_Text=NULL WHERE Beleg_Nr='CR';
  70. """)
  71. # The production query now reads a DWH view. Model that view in SQLite;
  72. # keep the detail overlays live so update/authorization tests still run real SQL.
  73. overlays = {
  74. 'Verursacher_Benutzer_ID': 'COALESCE(D.Verursacher_Benutzer_ID, F.Verursacher_Benutzer_ID)',
  75. 'Verursacher_Text': 'COALESCE(B.Name, D.Verursacher_Benutzer_ID, F.Verursacher_Text)',
  76. 'Wiedervorlage': 'COALESCE(D.Wiedervorlage, F.Beleg_Faellig_Datum)',
  77. 'Bearbeitet': "COALESCE(D.Bearbeitet, 'N')",
  78. 'Abwarten': "CASE WHEN D.Reklamation='J' THEN 'Rekl.' WHEN D.Versicherung='J' THEN 'Vers.' "
  79. "WHEN D.Rechtsanwalt='J' THEN 'RA' WHEN D.Mahnen_aussetzen='J' THEN 'N' ELSE '' END",
  80. }
  81. projection = ', '.join(f'{overlays.get(name, "F.[" + name + "]")} AS [{name}]' for name in columns)
  82. self.db.connection.execute(
  83. 'CREATE VIEW dbo.Forderungen_Liste AS SELECT ' + projection +
  84. ' FROM Forderungen_Kopf F LEFT JOIN Forderungen_Details D ON F.Client_DB=D.Client_DB AND F.Beleg_Nr=D.Beleg_Nr'
  85. ' LEFT JOIN Benutzer_Rolle B ON D.Verursacher_Benutzer_ID=B.Benutzer_ID'
  86. ' LEFT JOIN Garantie2 G ON F.Client_DB=G.Client_DB AND F.Beleg_Nr=G.Beleg_Nr'
  87. )
  88. self.db.connection.commit()
  89. execute = self.db.execute
  90. def dialect(query, values=None):
  91. query = str(query)
  92. for column in ["Kommentar_Fibu", "Kommentar_Abteilung"]:
  93. query = query.replace(
  94. f"left(isnull([FK].[{column}], ''), 50)", f"substr(ifnull([FK].[{column}], ''), 1, 50)"
  95. )
  96. query = (
  97. query.replace("isnull(", "ifnull(")
  98. .replace("nvarchar(max)", "TEXT")
  99. .replace("dateadd(day, 4, getdate())", "'2026-11-06'")
  100. .replace("getdate()", "'2026-11-02'")
  101. )
  102. if query.strip().startswith("SELECT F.* FROM"):
  103. self.db.calls.append((query, values))
  104. cursor = self.db.connection.execute(query, values or {})
  105. columns = [item[0] for item in cursor.description]
  106. record = namedtuple("DashboardInvoice", columns)
  107. rows = []
  108. for raw in cursor.fetchall():
  109. row = dict(zip(columns, raw))
  110. for name in ["Beleg_Datum", "Beleg_Faellig_Datum", "Wiedervorlage"]:
  111. if row.get(name):
  112. row[name] = datetime.fromisoformat(row[name])
  113. rows.append(record(**row))
  114. class SQLRows:
  115. def keys(self):
  116. return columns
  117. def fetchall(self):
  118. return rows
  119. return SQLRows()
  120. result = execute(query, values)
  121. if ' AS value, MIN(' in query:
  122. result.rows = [tuple(row.values()) for row in result.rows]
  123. return result
  124. adapter = patch.object(self.db, "execute", side_effect=dialect)
  125. adapter.start()
  126. self.addCleanup(adapter.stop)
  127. self.now = datetime(2026, 11, 2, 10, tzinfo=BERLIN)
  128. def data(self):
  129. return build_dashboard(self.db, "tester", self.now)
  130. def test_totals_rights_duplicate_roles_and_duplicate_join_rows(self):
  131. data = self.data()
  132. self.assertEqual(data["count"], 5)
  133. self.assertEqual(data["amount"], "200.00")
  134. self.assertEqual(data["amount_display"], "200,00")
  135. for groups in data["views"].values():
  136. self.assertEqual(sum(group["count"] for group in groups), 5)
  137. self.assertEqual(sum(Decimal(group["amount"]) for group in groups), Decimal(200))
  138. sql, values = self.db.calls[-1]
  139. self.assertIn("WHERE EXISTS", sql)
  140. self.assertEqual(values["auth_username"], "tester")
  141. self.assertTrue(all("AAM" not in group["label"] for group in data["views"]["Standort"]))
  142. def test_only_invoices_filters_all_aggregates_and_drilldowns(self):
  143. data = build_dashboard(self.db, "tester", self.now, only_invoices=True)
  144. self.assertEqual(data["count"], 4)
  145. self.assertEqual(data["amount"], "240.00")
  146. self.assertEqual(self.data()["count"], 5)
  147. for groups in data["views"].values():
  148. self.assertEqual(sum(group["count"] for group in groups), 4)
  149. for group in groups:
  150. for item in [group, *group["segments"]]:
  151. params = parse_qs(urlsplit(item["url"]).query)
  152. self.assertEqual(params['RechnungGutschrift'], ['Rechnung'])
  153. self.assertNotIn('dashboard_segment', params)
  154. response = self.client.get(item["url"])
  155. self.assertIn('id="list-count">' + str(item["count"]), response.text)
  156. self.assertIn('name="RechnungGutschrift" value="Rechnung"', response.text)
  157. self.db.connection.execute(
  158. "UPDATE dbo.Forderungen_Kopf SET Rechnung_Gutschrift='Rechnungskorrektur' WHERE Beleg_Nr='CR'"
  159. )
  160. self.db.connection.execute(
  161. "UPDATE dbo.Forderungen_Kopf SET Beleg_Offen=-100 WHERE Client_DB='1' AND Beleg_Nr='R1'"
  162. )
  163. negative = build_dashboard(self.db, "tester", self.now, only_invoices=True)
  164. self.assertEqual(negative["count"], 4)
  165. self.assertEqual(negative["amount"], "40.00")
  166. def test_invoice_checkbox_url_state_and_strict_segment_flag(self):
  167. response = self.client.get("/app/forderungen/dashboard?page=Verursacher&metric=anzahl&only_invoices=true")
  168. self.assertEqual(response.status_code, 200)
  169. self.assertIn('name="only_invoices" value="true" checked', response.text)
  170. self.assertIn('name="page" value="Verursacher"', response.text)
  171. self.assertIn('name="metric" value="anzahl"', response.text)
  172. self.assertIn("240,00", response.text)
  173. unchecked = self.client.get("/app/forderungen/dashboard")
  174. self.assertNotIn('name="only_invoices" value="true" checked', unchecked.text)
  175. self.assertIn("200,00", unchecked.text)
  176. self.assertIn('data-dashboard-view="Standort" data-dashboard-metric=', unchecked.text)
  177. for invalid in ["true", 1, None]:
  178. with self.assertRaises(ValueError):
  179. parse_segment(pack_segment({"view": "Altersstaffel", "key": None, "only_invoices": invalid}))
  180. def test_all_segments_round_trip_to_exact_list_count_and_amount(self):
  181. for view, groups in self.data()["views"].items():
  182. for group in groups:
  183. for segment in group["segments"]:
  184. with self.subTest(view=view, label=group["label"], department=segment["department"]):
  185. response = self.client.get(segment["url"])
  186. self.assertEqual(response.status_code, 200)
  187. count = int(re.search(r'id="list-count">(\d+)', response.text)[1])
  188. self.assertEqual(count, segment["count"])
  189. self.assertNotIn('name="dashboard_segment"', response.text)
  190. self.assertNotIn("Dashboardfilter entfernen", response.text)
  191. self.assertIn("Aktive Filter (", response.text)
  192. from app.jinja_templates import number_format
  193. self.assertIn(number_format(Decimal(segment["amount"])) + " €", response.text)
  194. def test_follow_up_source_staffels_and_21_day_boundary(self):
  195. cases = [
  196. ("2026-11-01", "1", "Überfällig"),
  197. ("2026-11-02", "2", "In 3 Tagen fällig"),
  198. ("2026-11-05 23:30:00", "2", "In 3 Tagen fällig"),
  199. ("2026-11-06", "3", "Noch nicht fällig"),
  200. ("2026-11-17", "3", "Noch nicht fällig"),
  201. ("2026-11-22 23:30:00", "3", "Noch nicht fällig"),
  202. ("2026-11-23", "4", "zurückgestellt"),
  203. ]
  204. for value, expected, label in cases:
  205. with self.subTest(date=value):
  206. self.db.connection.execute(
  207. "UPDATE dbo.Forderungen_Details SET Wiedervorlage=? WHERE Beleg_Nr='R1'", (value,)
  208. )
  209. self.db.connection.execute(
  210. "UPDATE dbo.Forderungen_Kopf SET Wiedervorlage_Staffel_Nr=?, Wiedervorlage_Staffel_Text=? WHERE Beleg_Nr='R1'",
  211. (expected, label),
  212. )
  213. data = build_dashboard(self.db, "tester", self.now, stack="wiedervorlage")
  214. group = next(group for group in data["views"]["Altersstaffel"] if group["label"] == "< 4 Wochen")
  215. segment = group['segments'][0]
  216. self.assertEqual(segment["id"], expected)
  217. self.assertEqual(segment["label"], label)
  218. self.assertEqual(parse_qs(urlsplit(segment['url']).query)['WVStaffel'], [expected])
  219. response = self.client.get(segment["url"])
  220. self.assertIn('id="list-count">1', response.text)
  221. self.assertIn(f'aria-label="{label} entfernen"', response.text)
  222. ids = [item["id"] for item in data["stack_items"]]
  223. self.assertEqual(ids, ["2", "3", "4", None])
  224. self.assertNotIn("Wiedervorlage", data["views"])
  225. def test_follow_up_uses_source_staffel_even_when_dates_disagree(self):
  226. self.db.connection.execute("UPDATE dbo.Forderungen_Details SET Wiedervorlage=NULL WHERE Beleg_Nr='R1'")
  227. self.db.connection.execute(
  228. "UPDATE dbo.Forderungen_Kopf SET Beleg_Faellig_Datum='2099-01-01', "
  229. "Wiedervorlage_Staffel_Nr=10, Wiedervorlage_Staffel_Text='Bezeichnung aus Datenquelle' "
  230. "WHERE Client_DB='1' AND Beleg_Nr='R1'"
  231. )
  232. data = build_dashboard(self.db, "tester", self.now, stack="wiedervorlage")
  233. group = next(group for group in data["views"]["Altersstaffel"] if group["label"] == "< 4 Wochen")
  234. self.assertEqual(group["segments"][0]["id"], "10")
  235. self.assertEqual(group["segments"][0]["label"], "Bezeichnung aus Datenquelle")
  236. self.assertEqual([item['id'] for item in data['stack_items']], ['2', '3', '10', None])
  237. self.assertIn('id="list-count">1', self.client.get(group["segments"][0]["url"]).text)
  238. def test_follow_up_source_staffels_are_independent_of_dashboard_clock(self):
  239. self.db.connection.execute("UPDATE dbo.Forderungen_Details SET Wiedervorlage='2027-01-01' WHERE Beleg_Nr='R1'")
  240. now = datetime.fromisoformat("2026-12-28T23:30:00+00:00")
  241. groups = build_dashboard(self.db, "tester", now, stack="wiedervorlage")["views"]["Altersstaffel"]
  242. group = next(group for group in groups if group["label"] == "< 4 Wochen")
  243. params = parse_qs(urlsplit(group['segments'][0]['url']).query)
  244. self.assertEqual(decode_filter_values(params['Staffel'][0]), ['< 4 Wochen'])
  245. self.assertEqual(params['WVStaffel'], ['1'])
  246. self.assertNotIn('WVStaffel', parse_qs(urlsplit(group['url']).query))
  247. self.assertNotIn('WiedervorlageVon', params)
  248. self.assertNotIn('WiedervorlageBis', params)
  249. self.assertEqual(group["segments"][0]["id"], "1")
  250. def test_follow_up_stack_roundtrips_for_all_rubrics_and_invoice_scope(self):
  251. for only in [False, True]:
  252. data = build_dashboard(self.db, "tester", self.now, only_invoices=only, stack="wiedervorlage")
  253. for groups in data["views"].values():
  254. self.assertEqual(sum(group["count"] for group in groups), data["count"])
  255. self.assertEqual(sum(Decimal(group["amount"]) for group in groups), Decimal(data["amount"]))
  256. for group in groups:
  257. for segment in group["segments"]:
  258. params = parse_qs(urlsplit(segment['url']).query)
  259. self.assertNotIn('dashboard_segment', params)
  260. self.assertNotIn('WiedervorlageVon', params)
  261. self.assertNotIn('WiedervorlageBis', params)
  262. self.assertNotIn('Abteilung', params)
  263. self.assertEqual(params['WVStaffel'], [segment['id'] or '__empty__'])
  264. self.assertNotIn('WVStaffel', parse_qs(urlsplit(group['url']).query))
  265. response = self.client.get(segment["url"])
  266. self.assertEqual(response.status_code, 200)
  267. self.assertIn('id="list-count">' + str(segment["count"]), response.text)
  268. from app.jinja_templates import number_format
  269. self.assertIn(number_format(Decimal(segment['amount'])) + ' €', response.text)
  270. def test_stack_selection_and_legacy_follow_up_links(self):
  271. response = self.client.get("/app/forderungen/dashboard?stack=wiedervorlage&metric=anzahl&only_invoices=true")
  272. self.assertEqual(response.status_code, 200)
  273. self.assertNotIn('data-dashboard-view="Wiedervorlage"', response.text)
  274. self.assertIn('value="wiedervorlage" selected', response.text)
  275. self.assertIn('name="stack" value="wiedervorlage"', response.text)
  276. self.assertEqual(self.client.get("/app/forderungen/dashboard?stack=bad").status_code, 422)
  277. old = self.client.get(
  278. "/app/forderungen/dashboard?page=Wiedervorlage&metric=anzahl&only_invoices=true", follow_redirects=False
  279. )
  280. self.assertEqual(old.status_code, 303)
  281. params = parse_qs(urlsplit(old.headers["location"]).query)
  282. self.assertEqual(
  283. params, {"page": ["Standort"], "metric": ["anzahl"], "stack": ["wiedervorlage"], "only_invoices": ["true"]}
  284. )
  285. legacy = pack_segment({"view": "Wiedervorlage", "key": {"from": "2026-11-02", "to": "2026-11-09"}})
  286. response = self.client.get("/app/forderungen/liste", params={"dashboard_segment": legacy})
  287. self.assertIn('id="list-count">5', response.text)
  288. self.assertEqual(urlsplit(str(response.url)).query, '')
  289. for bad in [True, "tomorrow", {"from": "2026-11-02", "to": "2026-11-01"}]:
  290. with self.assertRaises(ValueError):
  291. parse_segment(pack_segment({"view": "Altersstaffel", "key": None, "follow_up": bad}))
  292. def test_same_names_share_normal_filter_but_clients_remain_separate(self):
  293. groups = [group for group in self.data()["views"]["Verursacher"] if group["label"] == 'Alice']
  294. self.assertEqual(len(groups), 1)
  295. self.assertEqual(groups[0]['count'], 2)
  296. self.assertEqual(parse_qs(urlsplit(groups[0]['url']).query), {'Verursacher': ['Alice']})
  297. self.db.connection.execute("INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','2','10','Service')")
  298. groups = self.data()["views"]["Standort"]
  299. self.assertEqual(len(groups), 2)
  300. for group in groups:
  301. self.assertEqual(self.client.get(group["url"]).status_code, 200)
  302. self.assertEqual({group["label"] for group in groups}, {"ULM (AHR)", "ULM (AAM)"})
  303. def test_decimal_sum_and_zero_amount_segments_still_have_links(self):
  304. self.db.connection.execute(
  305. "UPDATE dbo.Forderungen_Kopf SET Beleg_Offen=0.1 WHERE Client_DB='1' AND Beleg_Nr='R1'"
  306. )
  307. self.db.connection.execute(
  308. "UPDATE dbo.Forderungen_Kopf SET Beleg_Offen=0.2 WHERE Client_DB='1' AND Beleg_Nr='X1'"
  309. )
  310. data = self.data()
  311. self.assertEqual(data["amount"], "10.30")
  312. zero = build_dashboard(self.db, "tester", self.now, stack="wiedervorlage")
  313. self.assertTrue(
  314. any(
  315. segment["amount"] == "0.00" and segment["url"]
  316. for group in zero["views"]["Verursacher"]
  317. for segment in group["segments"]
  318. )
  319. )
  320. self.assertEqual(color("Service"), color("Service"))
  321. def test_exact_department_filter_does_not_match_prefixes(self):
  322. self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Abteilung='Service2' WHERE Beleg_Nr='CR'")
  323. self.db.connection.execute("INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','1','10','Service2')")
  324. group = self.data()["views"]["Standort"][0]
  325. segment = next(segment for segment in group["segments"] if segment["department"] == "Service")
  326. exact = self.client.get(segment["url"])
  327. partial = self.client.get("/app/forderungen/liste?Abteilung=Service")
  328. self.assertIn('id="list-count">4', exact.text)
  329. self.assertIn('id="list-count">4', partial.text)
  330. def test_dashboard_filters_can_be_changed_without_hidden_constraints(self):
  331. from app.navigation import list_url
  332. group = self.data()['views']['Standort'][0]
  333. segment = next(item for item in group['segments'] if item['department'] == 'Service')
  334. params = {name: values[0] for name, values in parse_qs(urlsplit(segment['url']).query).items()}
  335. self.assertEqual(params, {'Mandant': '1', 'Filiale': '10', 'Abteilung': 'Service'})
  336. response = self.client.get(segment['url'])
  337. self.assertIn('id="list-count">4', response.text)
  338. self.assertIn('data-remove="Service"', response.text)
  339. params.pop('Abteilung')
  340. response = self.client.get(list_url(params))
  341. self.assertIn('id="list-count">5', response.text)
  342. self.assertNotIn('name="dashboard_segment"', response.text)
  343. params['Staffel'] = '%3C%204%20Wochen'
  344. response = self.client.get(list_url(params))
  345. self.assertIn('id="list-count">1', response.text)
  346. def test_old_dashboard_link_redirects_to_visible_filters(self):
  347. legacy = pack_segment({'view': 'Standort', 'key': {'client': '1', 'site': '10'}, 'department': 'Service'})
  348. response = self.client.get('/app/forderungen/liste', params={'dashboard_segment': legacy}, follow_redirects=False)
  349. self.assertEqual(response.status_code, 307)
  350. self.assertEqual(response.headers['location'], '/app/forderungen/liste?Mandant=1&Filiale=10&Abteilung=Service')
  351. response = self.client.get(response.headers['location'])
  352. self.assertIn('id="list-count">4', response.text)
  353. self.assertIn('data-remove="Service"', response.text)
  354. self.assertNotIn('dashboard_segment', response.text)
  355. self.assertNotIn('Dashboardfilter', response.text)
  356. def test_old_person_id_is_translated_to_normal_name_filter(self):
  357. legacy = pack_segment({'view': 'Verursacher', 'key': {'id': 'alice'}, 'only_invoices': True})
  358. response = self.client.get('/app/forderungen/liste', params={'dashboard_segment': legacy})
  359. params = parse_qs(urlsplit(str(response.url)).query)
  360. self.assertEqual(params, {'Verursacher': ['Alice'], 'RechnungGutschrift': ['Rechnung']})
  361. self.assertIn('id="list-count">2', response.text)
  362. self.assertIn('data-remove="Alice"', response.text)
  363. self.assertNotIn('dashboard_segment', response.text)
  364. def test_explicit_filters_override_old_link_and_login_recovers_visible_filters(self):
  365. legacy = pack_segment({'view': 'Standort', 'key': {'client': '1', 'site': '10'}, 'department': 'Service'})
  366. response = self.client.get('/app/forderungen/liste', params={'dashboard_segment': legacy, 'Filiale': '20'})
  367. self.assertEqual(parse_qs(urlsplit(str(response.url)).query)['Filiale'], ['20'])
  368. self.assertIn('id="list-count">0', response.text)
  369. self.client.cookies.clear()
  370. response = self.client.get('/app/forderungen/liste', params={'dashboard_segment': legacy}, headers={'HX-Request': 'true'})
  371. target = parse_qs(urlsplit(response.headers['HX-Redirect']).query)['next'][0]
  372. self.assertEqual(target, '/app/forderungen/liste?Mandant=1&Filiale=10&Abteilung=Service')
  373. def test_new_links_preserve_names_with_commas_and_spaces(self):
  374. self.db.connection.execute("UPDATE dbo.Benutzer_Rolle SET Name='Link, Florian' WHERE Benutzer_ID='alice'")
  375. group = next(item for item in self.data()['views']['Verursacher'] if item['label'] == 'Link, Florian')
  376. self.assertIn('Verursacher=Link%252C%2520Florian', group['url'])
  377. response = self.client.get(group['url'])
  378. self.assertIn('id="list-count">1', response.text)
  379. self.assertIn('data-remove="Link, Florian"', response.text)
  380. def test_forged_segment_cannot_expand_invoice_access(self):
  381. raw = pack_segment({"view": "Standort", "key": {"client": "2", "site": "10"}})
  382. response = self.client.get("/app/forderungen/liste", params={"dashboard_segment": raw})
  383. self.assertEqual(response.status_code, 200)
  384. self.assertIn('id="list-count">0', response.text)
  385. self.assertIn("0,00 €", response.text)
  386. def test_segment_preserved_through_detail_return_login_and_normal_filters(self):
  387. url = self.data()["views"]["Standort"][0]["url"]
  388. filters = parse_qs(urlsplit(url).query)
  389. returned = list_return_url(url, "1_R1")
  390. self.assertEqual(parse_qs(urlsplit(returned).query), {**filters, 'focus': ['1_R1']})
  391. detail = "/app/forderungen/details/1_R1?" + urlencode({"return_to": returned})
  392. recovered = normalize_page_url(
  393. "/app/forderungen/details/1_R1/kommentare", current_url="https://proxy.test" + detail
  394. )
  395. self.assertEqual(recovered, detail)
  396. response = self.client.get(url + "&Kunde=Test&page=1&limit=1")
  397. self.assertEqual(response.status_code, 200)
  398. self.assertIn('hx-include="#liste-filter"', response.text)
  399. sql, values = self.db.calls[-1]
  400. self.assertIn("F.[Client_DB] = :filter_mandant", sql)
  401. self.assertEqual(values["filter_kunde"], "%Test%")
  402. self.client.cookies.clear()
  403. response = self.client.get(url, headers={"HX-Request": "true"})
  404. target = parse_qs(urlsplit(response.headers["HX-Redirect"]).query)["next"][0]
  405. self.assertEqual(target, url)
  406. def test_bad_segments_are_rejected_before_data_query(self):
  407. invalid = [
  408. "not-json",
  409. "{}",
  410. '{"view":"Standort","key":{}}',
  411. '{"view":"Wiedervorlage","key":{"from":"x","to":null}}',
  412. '{"view":"Altersstaffel","key":null,"sql":"1=1"}',
  413. '{"view":"Verursacher","key":{"id":null}}',
  414. '{"view":"Standort","key":{"client":true,"site":"10"}}',
  415. ]
  416. for value in invalid:
  417. with self.subTest(value=value):
  418. self.db.calls.clear()
  419. response = self.client.get("/app/forderungen/liste", params={"dashboard_segment": value})
  420. self.assertEqual(response.status_code, 400)
  421. self.assertFalse(self.db.calls)
  422. def test_empty_error_and_invalid_dashboard_states(self):
  423. response = self.client.get("/app/forderungen/dashboard?page=Standort&metric=anzahl")
  424. self.assertEqual(response.status_code, 200)
  425. self.assertIn("Forderungen | Dashboard", response.text)
  426. self.assertIn('data-dashboard-metric="anzahl"', response.text)
  427. self.assertEqual(self.client.get("/app/forderungen/dashboard?page=bad").status_code, 422)
  428. self.db.connection.execute("DELETE FROM dbo.Benutzer_Rechte WHERE Benutzer_ID='tester'")
  429. data = self.data()
  430. self.assertEqual(data["count"], 0)
  431. self.assertEqual(data["amount"], "0.00")
  432. with (
  433. patch("app.forderung_dashboard.read_invoices", side_effect=RuntimeError("offline")),
  434. self.assertLogs("app.routes", level="ERROR"),
  435. ):
  436. response = self.client.get("/app/forderungen/dashboard")
  437. self.assertEqual(response.status_code, 503)
  438. self.assertIn("nicht geladen", response.text)
  439. self.assertNotIn("Anzahl der Belege", response.text)
  440. if __name__ == "__main__":
  441. unittest.main()