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) <noreply@anthropic.com>
This commit is contained in:
archeious 2026-04-17 13:04:06 -06:00
parent 1df3c1c218
commit 8fec2fdff7

View 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