"""Der einstellbare Monatsbeginn – der Gehaltstag statt des Monatsersten.""" from datetime import date import pytest from httpx import AsyncClient from app.core.clock import month_anchor, period_bounds, period_key, period_start from app.services.reports import current_period, period_of # --- Zeitrechnung --------------------------------------------------------------- def test_monatserster_bleibt_der_kalendermonat() -> None: assert period_bounds(date(2026, 9, 1), 1) == (date(2026, 9, 1), date(2026, 9, 30)) assert period_key(date(2026, 9, 30), 1) == date(2026, 9, 1) def test_gehaltstag_verschiebt_den_zeitraum() -> None: """Der Zeitraum trägt den Namen des Monats, in dem er beginnt.""" assert period_bounds(date(2026, 9, 1), 25) == (date(2026, 9, 25), date(2026, 10, 24)) assert period_key(date(2026, 9, 24), 25) == date(2026, 8, 1) assert period_key(date(2026, 9, 25), 25) == date(2026, 9, 1) def test_starttag_jenseits_der_monatslaenge_trifft_den_letzten() -> None: """Der 31. bedeutet „letzter Tag des Monats“ – auch im Februar.""" assert month_anchor(date(2026, 2, 10), 31) == date(2026, 2, 28) assert period_bounds(date(2026, 1, 1), 31) == (date(2026, 1, 31), date(2026, 2, 27)) assert period_bounds(date(2026, 2, 1), 31) == (date(2026, 2, 28), date(2026, 3, 30)) @pytest.mark.parametrize("start_day", [1, 5, 15, 25, 28, 31]) def test_zeitraeume_schliessen_lueckenlos_aneinander_an(start_day: int) -> None: """Kein Tag fällt zwischen zwei Zeiträume, und keiner zählt doppelt.""" monat = date(2025, 12, 1) _, vorher_ende = period_bounds(monat, start_day) for versatz in range(1, 18): schluessel = date( monat.year + (monat.month - 1 + versatz) // 12, (monat.month - 1 + versatz) % 12 + 1, 1, ) beginn, ende = period_bounds(schluessel, start_day) assert (beginn - vorher_ende).days == 1 assert period_start(beginn, start_day) == beginn assert period_start(ende, start_day) == beginn vorher_ende = ende def test_current_period_richtet_sich_nach_dem_stichtag() -> None: zeitraum = current_period(25, date(2026, 9, 10)) assert (zeitraum.key, zeitraum.start, zeitraum.end) == ( date(2026, 8, 1), date(2026, 8, 25), date(2026, 9, 24), ) # --- Einstellung über die API --------------------------------------------------- async def test_einstellung_lesen_und_speichern(auth_client: AsyncClient) -> None: antwort = await auth_client.get("/api/settings") assert antwort.status_code == 200 assert antwort.json()["month_start_day"] == 1 antwort = await auth_client.put("/api/settings", json={"month_start_day": 25}) assert antwort.status_code == 200 daten = antwort.json() assert daten["month_start_day"] == 25 assert daten["current_period_start"].endswith("-25") assert (await auth_client.get("/api/settings")).json()["month_start_day"] == 25 async def test_einstellung_weist_ungueltige_tage_ab(auth_client: AsyncClient) -> None: for tag in (0, 32): antwort = await auth_client.put("/api/settings", json={"month_start_day": tag}) assert antwort.status_code == 422, antwort.text # --- Wirkung auf die Auswertungen ----------------------------------------------- async def _buchung(client: AsyncClient, seeded: dict, tag: str, betrag: str) -> None: antwort = await client.post( "/api/transactions", json={ "kind": "expense", "title": f"Einkauf {tag}", "category_id": seeded["lebensmittel"], "account_id": seeded["account_id"], "amount": betrag, "booking_date": tag, }, ) assert antwort.status_code == 201, antwort.text async def test_monatsuebersicht_folgt_dem_gehaltstag( auth_client: AsyncClient, seeded: dict ) -> None: """Der 20. September gehört zum August, der 26. zum September.""" await _buchung(auth_client, seeded, "2026-09-20", "40.00") await _buchung(auth_client, seeded, "2026-09-26", "60.00") await auth_client.put("/api/settings", json={"month_start_day": 25}) antwort = await auth_client.get("/api/reports/month", params={"month": "2026-09-01"}) assert antwort.status_code == 200 daten = antwort.json() assert daten["period_start"] == "2026-09-25" assert daten["period_end"] == "2026-10-24" assert daten["planned"]["expenses"] == "60.00" # Der 20.09. liegt im Vormonat, also im Zeitraum August. assert daten["previous_planned"]["expenses"] == "40.00" async def test_kalender_laeuft_vom_gehaltstag_bis_zum_vortag( auth_client: AsyncClient, seeded: dict ) -> None: await auth_client.put("/api/settings", json={"month_start_day": 25}) antwort = await auth_client.get("/api/reports/calendar", params={"month": "2026-09-01"}) assert antwort.status_code == 200 daten = antwort.json() assert daten["month"] == "2026-09-01" assert daten["period_start"] == "2026-09-25" assert daten["period_end"] == "2026-10-24" assert daten["days"][0]["date"] == "2026-09-25" assert daten["days"][-1]["date"] == "2026-10-24" assert len(daten["days"]) == 30 async def test_vorschau_beginnt_mit_dem_gehaltsmonat( auth_client: AsyncClient, seeded: dict ) -> None: await auth_client.put("/api/settings", json={"month_start_day": 25}) antwort = await auth_client.get( "/api/reports/forecast", params={"months": 3, "start": "2026-09-01"} ) assert antwort.status_code == 200 monate = antwort.json()["months"] assert [monat["month"] for monat in monate] == ["2026-09-01", "2026-10-01", "2026-11-01"] assert monate[0]["period_start"] == "2026-09-25" assert monate[2]["period_end"] == "2026-12-24" async def test_budget_zaehlt_nur_ausgaben_des_gehaltsmonats( auth_client: AsyncClient, seeded: dict ) -> None: antwort = await auth_client.post( "/api/budgets", json={ "category_id": seeded["lebensmittel"], "period_month": "2026-09-01", "limit_amount": "500.00", }, ) assert antwort.status_code == 201, antwort.text await _buchung(auth_client, seeded, "2026-09-20", "40.00") await _buchung(auth_client, seeded, "2026-10-05", "60.00") await auth_client.put("/api/settings", json={"month_start_day": 25}) antwort = await auth_client.get("/api/reports/budgets", params={"month": "2026-09-01"}) assert antwort.status_code == 200 stand = next( eintrag for eintrag in antwort.json() if eintrag["category_id"] == seeded["lebensmittel"] ) # 20.09. liegt vor dem Gehaltstag, 05.10. danach. assert stand["spent"] == "60.00" async def test_dashboard_liefert_den_eingestellten_monatsbeginn( auth_client: AsyncClient, seeded: dict ) -> None: await auth_client.put("/api/settings", json={"month_start_day": 25}) antwort = await auth_client.get("/api/reports/dashboard", params={"month": "2026-09-01"}) assert antwort.status_code == 200 daten = antwort.json() assert daten["month_start_day"] == 25 assert daten["month"]["period_start"] == "2026-09-25" def test_period_of_kuerzt_beliebige_tage_auf_den_monatsersten() -> None: zeitraum = period_of(date(2026, 9, 17), 25) assert zeitraum.key == date(2026, 9, 1) assert zeitraum.start == date(2026, 9, 25)