From e80a3508b6770836d2a55059d47359d245b50ce3 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 17:34:53 -0600 Subject: [PATCH] test: cover posting CRUD and update existing tests to use the ledger New test_postings.py walks service and route layers: add sums into applied, negatives are allowed, update and delete round-trip, entry deletion cascades postings, order is desc by date, update_month_entry rejects the removed applied kwarg. Route tests assert HTTP behaviour, invalid-date rejection, closed-month lock, tone flip after a posting, and the "N txns" count badge renders. Existing tests that previously set applied via update_month_entry or the entries route now use add_posting or POST to /postings. Format assertions updated to match the new thousands-separator number rendering and the replaced entry-notes-row markup. Refs #19 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_groups.py | 7 +- tests/test_month_lifecycle.py | 44 ++++--- tests/test_month_routes.py | 15 +-- tests/test_month_service.py | 13 +- tests/test_notes.py | 3 +- tests/test_postings.py | 226 ++++++++++++++++++++++++++++++++++ tests/test_zero_amount.py | 20 +-- 7 files changed, 291 insertions(+), 37 deletions(-) create mode 100644 tests/test_postings.py diff --git a/tests/test_groups.py b/tests/test_groups.py index d877f7a..7bf06bf 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import date from decimal import Decimal from quartermaster import month_service, service @@ -65,10 +66,10 @@ def test_month_group_views_planned_and_applied(db): service.add_entry(db, Section.fixed_bill, "Rent", Decimal("1200.00")) service.add_entry(db, Section.sinking_fund, "Emergency", Decimal("300.00")) month = month_service.create_month(db, "2026-04") - # seed applied values + # seed applied values via a posting 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") + month_service.add_posting( + db, month, rent.id, date.today(), Decimal("1200.00"), description="test" ) db.refresh(month) views = {v.group: v for v in month_service.month_group_views(month)} diff --git a/tests/test_month_lifecycle.py b/tests/test_month_lifecycle.py index f75ee66..24589c3 100644 --- a/tests/test_month_lifecycle.py +++ b/tests/test_month_lifecycle.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import date from decimal import Decimal import pytest @@ -16,10 +17,10 @@ def _seed(db, balance_to_zero=False): 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 + # Post one transaction per entry matching the planned amount for entry in month.entries: - month_service.update_month_entry( - db, month, entry.id, applied=entry.planned + month_service.add_posting( + db, month, entry.id, date.today(), entry.planned, description="seed" ) db.refresh(month) return month @@ -61,8 +62,8 @@ def test_close_requires_zero_balance(db): # 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") + month_service.add_posting( + db, month, food.id, date.today(), Decimal("50.00"), description="seed" ) db.refresh(month) with pytest.raises(month_service.MonthLifecycleError) as exc: @@ -128,13 +129,16 @@ def _seed_via_api(client, balance_to_zero=False): if balance_to_zero: # entry ids 1,2,3 in that order client.post( - "/month/2026-04/entries/1", data={"applied": "1000.00"} + "/month/2026-04/entries/1/postings", + data={"occurred_on": "2026-04-01", "amount": "1000.00"}, ) client.post( - "/month/2026-04/entries/2", data={"applied": "600.00"} + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "600.00"}, ) client.post( - "/month/2026-04/entries/3", data={"applied": "400.00"} + "/month/2026-04/entries/3/postings", + data={"occurred_on": "2026-04-15", "amount": "400.00"}, ) @@ -148,8 +152,11 @@ def test_activate_route(client): 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"}) + # post an expense without income to produce an unbalanced state + client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "100.00"}, + ) response = client.post("/month/2026-04/close") assert response.status_code == 400 assert "0.00" in response.json()["detail"] @@ -182,7 +189,8 @@ def test_mutations_rejected_on_closed_month(client): ) assert add_response.status_code == 400 update_response = client.post( - "/month/2026-04/entries/1", data={"applied": "1200.00"} + "/month/2026-04/entries/1/postings", + data={"occurred_on": "2026-04-15", "amount": "50.00"}, ) assert update_response.status_code == 400 delete_response = client.delete("/month/2026-04/entries/1") @@ -201,8 +209,11 @@ def test_month_page_shows_planning_badge_and_activate_button(client): 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"}) + # force imbalance via a posting + client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "100.00"}, + ) response = client.get("/month/2026-04") assert response.status_code == 200 assert "state-active" in response.text @@ -237,7 +248,10 @@ def test_closed_month_inputs_are_disabled(client): 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 + # Editable inputs are replaced by readonly spans on closed months, + # so the name/planned inputs should not appear. + assert 'name="planned"' not in response.text + assert 'name="name"' not in response.text + assert "class=\"readonly\"" in response.text # delete buttons not rendered assert "Delete Paycheck" not in response.text diff --git a/tests/test_month_routes.py b/tests/test_month_routes.py index a9b176d..75fc06d 100644 --- a/tests/test_month_routes.py +++ b/tests/test_month_routes.py @@ -38,23 +38,24 @@ def test_created_month_renders_snapshot(client): 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 + # totals: applied / planned rendered with thousands separators + assert "$2,500.00" in response.text + assert "$1,200.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) + # rent is month_entry id=2 (after income id=1). Post a transaction so + # applied accumulates to 1200. response = client.post( - "/month/2026-04/entries/2", data={"applied": "1200.00"} + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "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 + assert "$1,200.00" in response.text or "$1200.00" in response.text def test_name_edit_flips_to_modified(client): diff --git a/tests/test_month_service.py b/tests/test_month_service.py index 11a69e0..837e6f2 100644 --- a/tests/test_month_service.py +++ b/tests/test_month_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import date from decimal import Decimal import pytest @@ -8,6 +9,13 @@ from quartermaster import month_service, service from quartermaster.models import Section +def _apply(db, month, entry_id, amount): + """Record an applied amount against a month entry by posting.""" + return month_service.add_posting( + db, month, entry_id, date.today(), Decimal(str(amount)), description="test" + ) + + def _seed_budget(db): income = service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00")) rent = service.add_entry( @@ -96,9 +104,8 @@ 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") - ) + _apply(db, month, rent.id, "1200.00") + db.refresh(month) view = month_service.section_view(month, Section.fixed_bill, "Fixed") assert view.total_applied == Decimal("1200.00") assert view.total_planned == Decimal("1200.00") diff --git a/tests/test_notes.py b/tests/test_notes.py index 498a526..e652528 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -155,5 +155,6 @@ def test_month_page_renders_notes_inputs(client): client.post("/month/2026-04/create") response = client.get("/month/2026-04") assert response.status_code == 200 - assert "entry-notes-row" in response.text + # notes input lives inside the expandable entry body now + assert 'name="notes"' in response.text assert "auto-pay" in response.text diff --git a/tests/test_postings.py b/tests/test_postings.py new file mode 100644 index 0000000..20f021a --- /dev/null +++ b/tests/test_postings.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +import pytest + +from quartermaster import month_service, service +from quartermaster.models import Posting, Section + + +def _seed_month(db, section=Section.fixed_bill, name="Rent", planned="1200"): + service.add_entry(db, Section.income, "Paycheck", Decimal("2500")) + service.add_entry(db, section, name, Decimal(planned)) + return month_service.create_month(db, "2026-04") + + +def test_add_posting_sums_into_applied(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + month_service.add_posting( + db, month, rent.id, date(2026, 4, 1), Decimal("600"), description="first half" + ) + month_service.add_posting( + db, month, rent.id, date(2026, 4, 15), Decimal("600"), description="second half" + ) + db.refresh(rent) + assert rent.applied == Decimal("1200.00") + assert len(rent.postings) == 2 + + +def test_empty_entry_applied_is_zero(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + assert rent.applied == Decimal("0.00") + assert rent.postings == [] + + +def test_negative_posting_allowed(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + month_service.add_posting( + db, month, rent.id, date(2026, 4, 1), Decimal("1200") + ) + month_service.add_posting( + db, month, rent.id, date(2026, 4, 3), Decimal("-50"), description="refund" + ) + db.refresh(rent) + assert rent.applied == Decimal("1150.00") + + +def test_update_posting(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + posting = month_service.add_posting( + db, month, rent.id, date(2026, 4, 1), Decimal("1100") + ) + month_service.update_posting( + db, month, posting.id, + amount=Decimal("1200"), + description="corrected", + occurred_on=date(2026, 4, 3), + ) + db.refresh(rent) + assert rent.applied == Decimal("1200.00") + updated = rent.postings[0] + assert updated.description == "corrected" + assert updated.occurred_on == date(2026, 4, 3) + + +def test_delete_posting(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + p1 = month_service.add_posting(db, month, rent.id, date.today(), Decimal("600")) + p2 = month_service.add_posting(db, month, rent.id, date.today(), Decimal("600")) + entry = month_service.delete_posting(db, month, p1.id) + db.refresh(rent) + assert entry.id == rent.id + assert rent.applied == Decimal("600.00") + assert len(rent.postings) == 1 + assert rent.postings[0].id == p2.id + + +def test_delete_entry_cascades_postings(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + month_service.add_posting(db, month, rent.id, date.today(), Decimal("600")) + month_service.add_posting(db, month, rent.id, date.today(), Decimal("600")) + # Postings count before deletion + all_postings = db.query(Posting).filter(Posting.month_entry_id == rent.id).count() + assert all_postings == 2 + month_service.delete_month_entry(db, month, rent.id) + db.expire_all() + remaining = db.query(Posting).filter(Posting.month_entry_id == rent.id).count() + assert remaining == 0 + + +def test_postings_ordered_desc(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + month_service.add_posting(db, month, rent.id, date(2026, 4, 1), Decimal("100")) + month_service.add_posting(db, month, rent.id, date(2026, 4, 15), Decimal("200")) + month_service.add_posting(db, month, rent.id, date(2026, 4, 8), Decimal("150")) + db.refresh(rent) + dates = [p.occurred_on for p in rent.postings] + assert dates == [date(2026, 4, 15), date(2026, 4, 8), date(2026, 4, 1)] + + +def test_update_month_entry_rejects_applied_kwarg(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + with pytest.raises(TypeError): + month_service.update_month_entry( + db, month, rent.id, applied=Decimal("500") + ) + + +# ----- Route-level tests ----- + +def _seed_via_api(client): + client.post( + "/sections/income/entries", + data={"name": "Paycheck", "amount": "2500"}, + ) + client.post( + "/sections/fixed_bill/entries", + data={"name": "Rent", "amount": "1200"}, + ) + client.post("/month/2026-04/create") + + +def test_add_posting_route(client): + _seed_via_api(client) + response = client.post( + "/month/2026-04/entries/2/postings", + data={ + "occurred_on": "2026-04-01", + "amount": "1200", + "description": "April rent", + "payee": "Landlord", + }, + ) + assert response.status_code == 200 + assert "April rent" in response.text + assert "Landlord" in response.text + + +def test_update_posting_route(client): + _seed_via_api(client) + client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "1100"}, + ) + # posting id is 1 (first created) + response = client.post( + "/month/2026-04/postings/1", + data={"amount": "1200", "description": "fixed amount"}, + ) + assert response.status_code == 200 + assert "fixed amount" in response.text + + +def test_delete_posting_route(client): + _seed_via_api(client) + client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "1200"}, + ) + response = client.delete("/month/2026-04/postings/1") + assert response.status_code == 200 + # The section partial should render with no postings and applied back to $0 + assert "$0.00" in response.text + + +def test_posting_routes_reject_invalid_date(client): + _seed_via_api(client) + response = client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "not-a-date", "amount": "100"}, + ) + assert response.status_code == 400 + + +def test_posting_rejected_on_closed_month(client): + _seed_via_api(client) + # Post balancing transactions so the month can close + client.post( + "/month/2026-04/entries/1/postings", + data={"occurred_on": "2026-04-01", "amount": "1200"}, + ) + client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "1200"}, + ) + client.post("/month/2026-04/activate") + close = client.post("/month/2026-04/close") + assert close.status_code == 204 + # Now try to add a new posting on the closed month + response = client.post( + "/month/2026-04/entries/1/postings", + data={"occurred_on": "2026-04-20", "amount": "1"}, + ) + assert response.status_code == 400 + + +def test_applied_update_via_posting_flips_zero_tone(client): + _seed_via_api(client) + response = client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "1200"}, + ) + assert response.status_code == 200 + # With only rent applied, applied zero = 0 - 1200 = -1200 -> negative + assert "tone-negative" in response.text + + +def test_posting_count_badge_renders(client): + _seed_via_api(client) + for i in range(3): + client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "400"}, + ) + page = client.get("/month/2026-04") + assert page.status_code == 200 + assert "3 txns" in page.text diff --git a/tests/test_zero_amount.py b/tests/test_zero_amount.py index 1c3d53e..fd4c90a 100644 --- a/tests/test_zero_amount.py +++ b/tests/test_zero_amount.py @@ -1,11 +1,18 @@ from __future__ import annotations +from datetime import date from decimal import Decimal from quartermaster import month_service, service from quartermaster.models import Section +def _apply(db, month, entry_id, amount): + return month_service.add_posting( + db, month, entry_id, date.today(), Decimal(str(amount)), description="test" + ) + + def test_zero_tone_classification(): assert service.zero_tone(Decimal("0")) == "zero" assert service.zero_tone(Decimal("0.00")) == "zero" @@ -66,12 +73,8 @@ def test_month_zero_reflects_applied_updates(db): month = month_service.create_month(db, "2026-04") income_entry = next(e for e in month.entries if e.origin_name == "Paycheck") rent_entry = next(e for e in month.entries if e.origin_name == "Rent") - month_service.update_month_entry( - db, month, income_entry.id, applied=Decimal("2500.00") - ) - month_service.update_month_entry( - db, month, rent_entry.id, applied=Decimal("1200.00") - ) + _apply(db, month, income_entry.id, "2500.00") + _apply(db, month, rent_entry.id, "1200.00") db.refresh(month) z = month_service.month_zero(month) # applied income 2500 - applied (1200 + 0) = 1300 @@ -117,9 +120,10 @@ def test_month_entry_update_returns_zero_widget_oob(client): data={"name": "Rent", "amount": "1200.00"}, ) client.post("/month/2026-04/create") - # updating rent applied should bring the month zero widget back with new values + # posting rent applied via the ledger should bring the month zero widget back with new values response = client.post( - "/month/2026-04/entries/2", data={"applied": "1200.00"} + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "1200.00"}, ) assert response.status_code == 200 assert 'id="zero-widget"' in response.text