Month lifecycle: Planning, Active, Closed with reconciliation gate #16
1 changed files with 243 additions and 0 deletions
243
tests/test_month_lifecycle.py
Normal file
243
tests/test_month_lifecycle.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue