From 8fec2fdff78ffbcec80670918cca1a5a03a750c5 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 13:04:06 -0600 Subject: [PATCH] test: cover lifecycle transitions, balance gate, and edit-locking Service tests walk Planning -> Active -> Closed -> Active and confirm rejects on out-of-order transitions. Close rejects when applied zero is nonzero; succeeds when balanced; reopens cleanly. Route tests confirm each endpoint's status codes, HX-Redirect headers, and that the page renders the right badge and button per state. Closed months reject every mutation with 400 and their rendered HTML carries disabled inputs without add forms or delete buttons. Refs #15 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_month_lifecycle.py | 243 ++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 tests/test_month_lifecycle.py diff --git a/tests/test_month_lifecycle.py b/tests/test_month_lifecycle.py new file mode 100644 index 0000000..f75ee66 --- /dev/null +++ b/tests/test_month_lifecycle.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from quartermaster import month_service, service +from quartermaster.models import MonthState, Section + + +def _seed(db, balance_to_zero=False): + service.add_entry(db, Section.income, "Paycheck", Decimal("1000.00")) + service.add_entry(db, Section.fixed_bill, "Rent", Decimal("600.00")) + service.add_entry(db, Section.debt_minimum, "Card A", Decimal("40.00")) + service.add_entry(db, Section.food, "Groceries", Decimal("300.00")) + service.add_entry(db, Section.other, "Misc", Decimal("60.00")) + month = month_service.create_month(db, "2026-04") + if balance_to_zero: + # Apply income fully and distribute applied to match planned exactly + for entry in month.entries: + month_service.update_month_entry( + db, month, entry.id, applied=entry.planned + ) + db.refresh(month) + return month + + +def test_create_month_defaults_to_planning(db): + _seed(db) + month = month_service.get_month(db, "2026-04") + assert month is not None + assert month.state == MonthState.planning + assert month.activated_at is None + assert month.closed_at is None + + +def test_activate_transitions_to_active(db): + month = _seed(db) + activated = month_service.activate_month(db, month) + assert activated.state == MonthState.active + assert activated.activated_at is not None + + +def test_activate_rejects_non_planning_month(db): + month = _seed(db) + month_service.activate_month(db, month) + with pytest.raises(month_service.MonthLifecycleError): + month_service.activate_month(db, month) + + +def test_close_requires_active(db): + month = _seed(db) + # still planning; close should reject + with pytest.raises(month_service.MonthLifecycleError): + month_service.close_month(db, month) + + +def test_close_requires_zero_balance(db): + month = _seed(db) + month_service.activate_month(db, month) + # applied zero is 0 - 0 = 0, but income applied is 0 so + # (0 income - 0 others) == 0 actually. Force a non-zero by applying to food. + food = next(e for e in month.entries if e.origin_name == "Groceries") + month_service.update_month_entry( + db, month, food.id, applied=Decimal("50.00") + ) + db.refresh(month) + with pytest.raises(month_service.MonthLifecycleError) as exc: + month_service.close_month(db, month) + assert "0.00" in str(exc.value) + + +def test_close_succeeds_when_balanced(db): + month = _seed(db, balance_to_zero=True) + month_service.activate_month(db, month) + closed = month_service.close_month(db, month) + assert closed.state == MonthState.closed + assert closed.closed_at is not None + + +def test_reopen_from_closed(db): + month = _seed(db, balance_to_zero=True) + month_service.activate_month(db, month) + month_service.close_month(db, month) + reopened = month_service.reopen_month(db, month) + assert reopened.state == MonthState.active + assert reopened.closed_at is None + + +def test_reopen_rejects_non_closed(db): + month = _seed(db) + with pytest.raises(month_service.MonthLifecycleError): + month_service.reopen_month(db, month) + + +def test_ensure_editable_rejects_closed(db): + month = _seed(db, balance_to_zero=True) + month_service.activate_month(db, month) + month_service.close_month(db, month) + with pytest.raises(month_service.MonthLifecycleError): + month_service.ensure_editable(month) + + +def test_ensure_editable_allows_planning_and_active(db): + month = _seed(db) + month_service.ensure_editable(month) # planning: fine + month_service.activate_month(db, month) + month_service.ensure_editable(month) # active: fine + + +# --- route-level ----------------------------------------------------------- + + +def _seed_via_api(client, balance_to_zero=False): + client.post( + "/sections/income/entries", + data={"name": "Paycheck", "amount": "1000.00"}, + ) + client.post( + "/sections/fixed_bill/entries", + data={"name": "Rent", "amount": "600.00"}, + ) + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00"}, + ) + client.post("/month/2026-04/create") + if balance_to_zero: + # entry ids 1,2,3 in that order + client.post( + "/month/2026-04/entries/1", data={"applied": "1000.00"} + ) + client.post( + "/month/2026-04/entries/2", data={"applied": "600.00"} + ) + client.post( + "/month/2026-04/entries/3", data={"applied": "400.00"} + ) + + +def test_activate_route(client): + _seed_via_api(client) + response = client.post("/month/2026-04/activate") + assert response.status_code == 204 + assert response.headers.get("hx-redirect") == "/month/2026-04" + + +def test_close_route_rejects_unbalanced(client): + _seed_via_api(client) + client.post("/month/2026-04/activate") + # apply some expense without income to produce an unbalanced state + client.post("/month/2026-04/entries/2", data={"applied": "100.00"}) + response = client.post("/month/2026-04/close") + assert response.status_code == 400 + assert "0.00" in response.json()["detail"] + + +def test_close_route_succeeds_balanced(client): + _seed_via_api(client, balance_to_zero=True) + client.post("/month/2026-04/activate") + response = client.post("/month/2026-04/close") + assert response.status_code == 204 + assert response.headers.get("hx-redirect") == "/month/2026-04" + + +def test_reopen_route(client): + _seed_via_api(client, balance_to_zero=True) + client.post("/month/2026-04/activate") + client.post("/month/2026-04/close") + response = client.post("/month/2026-04/reopen") + assert response.status_code == 204 + + +def test_mutations_rejected_on_closed_month(client): + _seed_via_api(client, balance_to_zero=True) + client.post("/month/2026-04/activate") + client.post("/month/2026-04/close") + # try to add, update, delete, and re-set target + add_response = client.post( + "/month/2026-04/sections/other/entries", + data={"name": "Nope", "planned": "10.00"}, + ) + assert add_response.status_code == 400 + update_response = client.post( + "/month/2026-04/entries/1", data={"applied": "1200.00"} + ) + assert update_response.status_code == 400 + delete_response = client.delete("/month/2026-04/entries/1") + assert delete_response.status_code == 400 + + +def test_month_page_shows_planning_badge_and_activate_button(client): + _seed_via_api(client) + response = client.get("/month/2026-04") + assert response.status_code == 200 + assert "state-planning" in response.text + assert "/activate" in response.text + assert "Activate" in response.text + + +def test_month_page_shows_active_badge_with_disabled_close_when_unbalanced(client): + _seed_via_api(client) + client.post("/month/2026-04/activate") + # force imbalance + client.post("/month/2026-04/entries/2", data={"applied": "100.00"}) + response = client.get("/month/2026-04") + assert response.status_code == 200 + assert "state-active" in response.text + assert "/close" in response.text + assert "disabled" in response.text + + +def test_month_page_shows_active_badge_with_enabled_close_when_balanced(client): + _seed_via_api(client, balance_to_zero=True) + client.post("/month/2026-04/activate") + response = client.get("/month/2026-04") + assert "state-active" in response.text + assert "/close" in response.text + # Close button is present without the disabled attribute on the button itself + assert 'type="submit"' in response.text + + +def test_month_page_shows_closed_badge_and_reopen_button(client): + _seed_via_api(client, balance_to_zero=True) + client.post("/month/2026-04/activate") + client.post("/month/2026-04/close") + response = client.get("/month/2026-04") + assert "state-closed" in response.text + assert "/reopen" in response.text + assert "Closed" in response.text + # add forms hidden on closed month + assert "hx-post=\"/month/2026-04/sections/" not in response.text + + +def test_closed_month_inputs_are_disabled(client): + _seed_via_api(client, balance_to_zero=True) + client.post("/month/2026-04/activate") + client.post("/month/2026-04/close") + response = client.get("/month/2026-04") + # name inputs rendered with disabled + assert "disabled" in response.text + # delete buttons not rendered + assert "Delete Paycheck" not in response.text