test: cover month snapshot, deviation states, and per-month target
Service tests assert that create_month produces origin fields matching the budget, that edits flip deviation_state to edited, that added rows are new_in_month, and that a budget entry deleted after snapshot leaves the month entry unchanged. Route tests exercise the create flow, applied updates, name edits producing the modified tag, per-month target isolation, and the malformed-year-month 404. Refs #3 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e7354ba8d6
commit
abdb68a29c
2 changed files with 287 additions and 0 deletions
139
tests/test_month_routes.py
Normal file
139
tests/test_month_routes.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_budget_via_api(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/income/entries",
|
||||||
|
data={"name": "Paycheck", "amount": "2500.00"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/sections/fixed_bill/entries",
|
||||||
|
data={"name": "Rent", "amount": "1200.00"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/sections/debt_minimum/entries",
|
||||||
|
data={"name": "Card A", "amount": "40.00"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_month_renders_create_flow(client):
|
||||||
|
response = client.get("/month/2026-04")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "No snapshot yet" in response.text
|
||||||
|
assert 'hx-post="/month/2026-04/create"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_month_redirects_via_htmx(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
response = client.post("/month/2026-04/create")
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert response.headers.get("hx-redirect") == "/month/2026-04"
|
||||||
|
|
||||||
|
|
||||||
|
def test_created_month_renders_snapshot(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
response = client.get("/month/2026-04")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Paycheck" in response.text
|
||||||
|
assert "Rent" in response.text
|
||||||
|
assert "Card A" in response.text
|
||||||
|
# totals: applied / planned
|
||||||
|
assert "$2500.00" in response.text
|
||||||
|
assert "$1200.00" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_applied_update_returns_section_partial(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
# fetch month page, identify rent month_entry id = 2 (after income id 1)
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/entries/2", data={"applied": "1200.00"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
# rent is in fixed_bill section; total_applied should now include 1200
|
||||||
|
assert 'id="section-fixed_bill"' in response.text
|
||||||
|
# two $1200.00 occurrences: total_applied and planned (same amount)
|
||||||
|
assert response.text.count("$1200.00") >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_name_edit_flips_to_modified(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/entries/2", data={"name": "Rent (April)"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "state-edited" in response.text
|
||||||
|
assert 'class="tag tag-edited">modified' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_new_entry_within_month_is_marked_new(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/sections/other/entries",
|
||||||
|
data={"name": "Gift", "planned": "50.00"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "state-new_in_month" in response.text
|
||||||
|
assert "new this month" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_month_entry(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
response = client.delete("/month/2026-04/entries/2")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Rent" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_month_debt_minimum_updates_target(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
# Card A is entry id 3 in the month snapshot
|
||||||
|
response = client.delete("/month/2026-04/entries/3")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "section-debt_target" in response.text
|
||||||
|
assert "No target selected" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_month_target(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post(
|
||||||
|
"/sections/debt_minimum/entries",
|
||||||
|
data={"name": "Card B", "amount": "60.00"},
|
||||||
|
)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
# Card B is month_entry id 4
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/target", data={"month_entry_id": "4"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Card B" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_target_isolated_between_months(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post(
|
||||||
|
"/sections/debt_minimum/entries",
|
||||||
|
data={"name": "Card B", "amount": "60.00"},
|
||||||
|
)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
client.post("/month/2026-05/create")
|
||||||
|
# Change April target to Card B (month_entry id 4 within April)
|
||||||
|
client.post("/month/2026-04/target", data={"month_entry_id": "4"})
|
||||||
|
may_page = client.get("/month/2026-05")
|
||||||
|
# May still points at Card A (copied from budget)
|
||||||
|
assert "Card A" in may_page.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_year_month_returns_404(client):
|
||||||
|
response = client.get("/month/2026-13")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_page_shows_month_nav(client):
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "This month" in response.text
|
||||||
148
tests/test_month_service.py
Normal file
148
tests/test_month_service.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from quartermaster import month_service, service
|
||||||
|
from quartermaster.models import Section
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_budget(db):
|
||||||
|
income = service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00"))
|
||||||
|
rent = service.add_entry(
|
||||||
|
db, Section.fixed_bill, "Rent", Decimal("1200.00")
|
||||||
|
)
|
||||||
|
card_a = service.add_entry(
|
||||||
|
db, Section.debt_minimum, "Card A", Decimal("40.00")
|
||||||
|
)
|
||||||
|
card_b = service.add_entry(
|
||||||
|
db, Section.debt_minimum, "Card B", Decimal("60.00")
|
||||||
|
)
|
||||||
|
service.set_debt_target(db, card_a.id)
|
||||||
|
return income, rent, card_a, card_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_year_month():
|
||||||
|
assert month_service.valid_year_month("2026-04")
|
||||||
|
assert month_service.valid_year_month("2026-12")
|
||||||
|
assert not month_service.valid_year_month("2026-00")
|
||||||
|
assert not month_service.valid_year_month("2026-13")
|
||||||
|
assert not month_service.valid_year_month("26-04")
|
||||||
|
assert not month_service.valid_year_month("2026/04")
|
||||||
|
|
||||||
|
|
||||||
|
def test_shift_year_month_rolls_years():
|
||||||
|
assert month_service.shift_year_month("2026-12", 1) == "2027-01"
|
||||||
|
assert month_service.shift_year_month("2026-01", -1) == "2025-12"
|
||||||
|
assert month_service.shift_year_month("2026-04", 0) == "2026-04"
|
||||||
|
assert month_service.shift_year_month("2026-04", 12) == "2027-04"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_month_snapshots_budget(db):
|
||||||
|
income, rent, card_a, card_b = _seed_budget(db)
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
assert month.year_month == "2026-04"
|
||||||
|
assert len(month.entries) == 4
|
||||||
|
by_source = {e.source_entry_id: e for e in month.entries}
|
||||||
|
assert by_source[income.id].planned == Decimal("2500.00")
|
||||||
|
assert by_source[income.id].origin_name == "Paycheck"
|
||||||
|
assert by_source[income.id].origin_planned == Decimal("2500.00")
|
||||||
|
assert by_source[income.id].applied == Decimal("0.00")
|
||||||
|
assert by_source[rent.id].section == Section.fixed_bill
|
||||||
|
assert month.target is not None
|
||||||
|
assert month.target.entry is not None
|
||||||
|
assert month.target.entry.source_entry_id == card_a.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_month_idempotent(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
first = month_service.create_month(db, "2026-04")
|
||||||
|
second = month_service.create_month(db, "2026-04")
|
||||||
|
assert first.id == second.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_month_rejects_bad_year_month(db):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
month_service.create_month(db, "2026-00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_deviation_state_reflects_edits(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
assert month_service.deviation_state(rent) == month_service.DeviationState.unchanged
|
||||||
|
|
||||||
|
month_service.update_month_entry(
|
||||||
|
db, month, rent.id, planned=Decimal("1300.00")
|
||||||
|
)
|
||||||
|
rent = next(e for e in month.entries if e.id == rent.id)
|
||||||
|
assert month_service.deviation_state(rent) == month_service.DeviationState.edited
|
||||||
|
|
||||||
|
|
||||||
|
def test_added_row_is_new_in_month(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
extra = month_service.add_month_entry(
|
||||||
|
db, month, Section.other, "Surprise", Decimal("20.00")
|
||||||
|
)
|
||||||
|
assert extra.origin_name is None
|
||||||
|
assert month_service.deviation_state(extra) == (
|
||||||
|
month_service.DeviationState.new_in_month
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_applied_update_changes_totals(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
month_service.update_month_entry(
|
||||||
|
db, month, rent.id, applied=Decimal("1200.00")
|
||||||
|
)
|
||||||
|
view = month_service.section_view(month, Section.fixed_bill, "Fixed")
|
||||||
|
assert view.total_applied == Decimal("1200.00")
|
||||||
|
assert view.total_planned == Decimal("1200.00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_month_target_is_isolated(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
april = month_service.create_month(db, "2026-04")
|
||||||
|
may = month_service.create_month(db, "2026-05")
|
||||||
|
april_b = next(
|
||||||
|
e for e in april.entries
|
||||||
|
if e.section == Section.debt_minimum and e.origin_name == "Card B"
|
||||||
|
)
|
||||||
|
month_service.set_month_target(db, april, april_b.id)
|
||||||
|
april_target = month_service.get_month_target(db, april)
|
||||||
|
may_target = month_service.get_month_target(db, may)
|
||||||
|
assert april_target.entry is not None
|
||||||
|
assert april_target.entry.origin_name == "Card B"
|
||||||
|
assert may_target.entry is not None
|
||||||
|
assert may_target.entry.origin_name == "Card A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_month_entry_returns_section(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
result = month_service.delete_month_entry(db, month, rent.id)
|
||||||
|
assert result == Section.fixed_bill
|
||||||
|
db.refresh(month)
|
||||||
|
remaining = [
|
||||||
|
e for e in month.entries if e.section == Section.fixed_bill
|
||||||
|
]
|
||||||
|
assert remaining == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_entry_deleted_after_snapshot_leaves_month_intact(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
# remove budget-side rent; month-side rent should still render unchanged
|
||||||
|
budget_rent = service.list_entries(db, Section.fixed_bill)[0]
|
||||||
|
service.delete_entry(db, budget_rent.id)
|
||||||
|
db.expire_all()
|
||||||
|
month_rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
assert month_rent.source_entry_id is None
|
||||||
|
assert month_service.deviation_state(month_rent) == (
|
||||||
|
month_service.DeviationState.unchanged
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue