diff --git a/tests/test_month_routes.py b/tests/test_month_routes.py new file mode 100644 index 0000000..a9b176d --- /dev/null +++ b/tests/test_month_routes.py @@ -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 diff --git a/tests/test_month_service.py b/tests/test_month_service.py new file mode 100644 index 0000000..11a69e0 --- /dev/null +++ b/tests/test_month_service.py @@ -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 + )