227 lines
7.4 KiB
Python
227 lines
7.4 KiB
Python
|
|
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
|