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) <noreply@anthropic.com>
This commit is contained in:
archeious 2026-04-17 17:34:53 -06:00
parent cca05fe9fc
commit e80a3508b6
7 changed files with 291 additions and 37 deletions

View file

@ -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)}

View file

@ -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

View file

@ -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):

View file

@ -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")

View file

@ -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

226
tests/test_postings.py Normal file
View file

@ -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

View file

@ -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