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