| {{ entry.name }} | -${{ '{:,.2f}'.format(entry.amount) }} | -- |
+
+ {% for entry in section.entries %}
+ {% if editing_id is not none and entry.id == editing_id %}
+
+ {% else %}
+
+
+ {{ entry.name }}{% if entry.notes %}{{ entry.notes }}{% endif %}
+
+ ${{ '{:,.2f}'.format(entry.amount) }}
+
+
- |
-
| - - | -|||
| No entries yet. | |||
|
-
-
- + add {{ section.label|lower }}- - |
- |||
| {% if target.entry %}{{ target.entry.name }}{% else %}No target selected.{% endif %} | diff --git a/tests/test_notes.py b/tests/test_notes.py index e652528..bfcbe7c 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -24,16 +24,6 @@ def test_add_entry_strips_and_nulls_empty_notes(db): assert entry.notes is None -def test_set_entry_notes_updates(db): - entry = service.add_entry(db, Section.food, "Groceries", Decimal("400.00")) - updated = service.set_entry_notes(db, entry.id, "weekly Costco run") - assert updated is not None - assert updated.notes == "weekly Costco run" - - -def test_set_entry_notes_missing_returns_none(db): - assert service.set_entry_notes(db, 9999, "oops") is None - def test_snapshot_copies_notes(db): service.add_entry( @@ -91,26 +81,39 @@ def test_create_entry_route_accepts_notes(client): assert "3 mo cushion" in response.text -def test_update_entry_notes_route(client): +def test_update_entry_notes_via_save_route(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00"}, + ) + response = client.post( + "/entries/1", + data={"name": "Groceries", "amount": "400.00", "notes": "weekly"}, + ) + assert response.status_code == 200 + assert "weekly" in response.text + + +def test_update_entry_notes_empty_clears_via_save_route(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00", "notes": "weekly"}, + ) + response = client.post( + "/entries/1", + data={"name": "Groceries", "amount": "400.00", "notes": ""}, + ) + assert response.status_code == 200 + assert "note-badge" not in response.text + + +def test_old_entry_notes_route_is_removed(client): client.post( "/sections/food/entries", data={"name": "Groceries", "amount": "400.00"}, ) response = client.post("/entries/1/notes", data={"notes": "weekly"}) - assert response.status_code == 200 - assert "weekly" in response.text - - -def test_update_entry_notes_empty_clears(client): - client.post( - "/sections/food/entries", - data={"name": "Groceries", "amount": "400.00", "notes": "weekly"}, - ) - response = client.post("/entries/1/notes", data={"notes": ""}) - assert response.status_code == 200 - # the input's value="" still renders but the placeholder kicks in; - # specifically, no literal "weekly" anymore - assert "value=\"weekly\"" not in response.text + assert response.status_code == 404 def test_create_month_entry_route_accepts_notes(client): @@ -136,17 +139,27 @@ def test_update_month_entry_route_accepts_notes(client): assert "auto-pay" in response.text -def test_budget_page_renders_notes_inputs(client): +def test_budget_page_renders_note_badge_when_notes_set(client): client.post( "/sections/fixed_bill/entries", data={"name": "Rent", "amount": "1200.00", "notes": "due 1st"}, ) response = client.get("/") assert response.status_code == 200 - assert "entry-notes-row" in response.text + assert "note-badge" in response.text assert "due 1st" in response.text +def test_budget_page_omits_note_badge_when_notes_empty(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00"}, + ) + response = client.get("/") + assert response.status_code == 200 + assert "note-badge" not in response.text + + def test_month_page_renders_notes_inputs(client): client.post( "/sections/fixed_bill/entries", diff --git a/tests/test_routes.py b/tests/test_routes.py index 60ba013..2fcd9db 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -86,3 +86,142 @@ def test_reject_non_debt_minimum_target(client): ) response = client.post("/debt-target", data={"debt_minimum_id": "1"}) assert response.status_code == 400 + + +def test_get_entry_edit_returns_edit_form(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.get("/entries/1/edit") + assert response.status_code == 200 + assert 'class="entry-row editing"' in response.text + assert 'name="name"' in response.text + assert 'name="amount"' in response.text + assert 'name="notes"' in response.text + assert 'value="Twitch"' in response.text + + +def test_get_entry_edit_missing_returns_404(client): + response = client.get("/entries/9999/edit") + assert response.status_code == 404 + + +def test_get_entry_edit_other_rows_stay_in_read_mode(client): + client.post("/sections/subscription/entries", data={"name": "Twitch", "amount": "10.99"}) + client.post("/sections/subscription/entries", data={"name": "Netflix", "amount": "15.49"}) + response = client.get("/entries/1/edit") + assert response.status_code == 200 + assert response.text.count('entry-row editing') == 1 + assert response.text.count('entry-row reading') == 1 + + +def test_post_entry_updates_name_and_amount(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.post( + "/entries/1", + data={"name": "Twitch Prime", "amount": "11.99", "notes": ""}, + ) + assert response.status_code == 200 + assert "Twitch Prime" in response.text + assert "$11.99" in response.text + # returns to read mode + assert 'class="entry-row reading"' in response.text + # OOB swaps for zero widget and group total + assert 'id="zero-widget"' in response.text + assert 'id="group-total-flexible"' in response.text + + +def test_post_entry_updates_notes_as_badge(client): + client.post( + "/sections/subscription/entries", + data={"name": "Spotify", "amount": "17.48"}, + ) + response = client.post( + "/entries/1", + data={"name": "Spotify", "amount": "17.48", "notes": "family plan"}, + ) + assert response.status_code == 200 + assert "note-badge" in response.text + assert "family plan" in response.text + + +def test_post_entry_debt_minimum_includes_target_oob(client): + client.post( + "/sections/debt_minimum/entries", + data={"name": "Card A", "amount": "50.00"}, + ) + response = client.post( + "/entries/1", + data={"name": "Card A", "amount": "60.00", "notes": ""}, + ) + assert response.status_code == 200 + assert 'id="section-debt_target"' in response.text + + +def test_post_entry_empty_name_returns_400(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.post( + "/entries/1", + data={"name": " ", "amount": "11.99", "notes": ""}, + ) + assert response.status_code == 400 + + +def test_post_entry_negative_amount_returns_400(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.post( + "/entries/1", + data={"name": "Twitch", "amount": "-1.00", "notes": ""}, + ) + assert response.status_code == 400 + + +def test_post_entry_non_numeric_amount_returns_400(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.post( + "/entries/1", + data={"name": "Twitch", "amount": "eleven", "notes": ""}, + ) + assert response.status_code == 400 + + +def test_post_entry_missing_returns_404(client): + response = client.post( + "/entries/9999", + data={"name": "Whatever", "amount": "1.00", "notes": ""}, + ) + assert response.status_code == 404 + + +def test_get_section_returns_read_mode(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + # enter edit mode first + edit = client.get("/entries/1/edit") + assert 'entry-row editing' in edit.text + # now "cancel" via GET /sections/{section} + response = client.get("/sections/subscription") + assert response.status_code == 200 + assert 'entry-row reading' in response.text + assert 'entry-row editing' not in response.text + + +def test_get_section_invalid_returns_422(client): + # FastAPI rejects an unknown Section enum value at routing + response = client.get("/sections/not_a_real_section") + assert response.status_code == 422 diff --git a/tests/test_service.py b/tests/test_service.py index c03ede5..90b7a67 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -41,3 +41,83 @@ def test_debt_target_cleared_on_delete(db): service.delete_entry(db, dm.id) target = service.get_debt_target(db) assert target.debt_minimum_id is None + + +def test_update_entry_name_only(db): + entry = service.add_entry(db, Section.subscription, "Twitch", Decimal("10.99")) + updated = service.update_entry(db, entry.id, name="Twitch Prime") + assert updated is not None + assert updated.name == "Twitch Prime" + assert updated.amount == Decimal("10.99") + + +def test_update_entry_amount_only(db): + entry = service.add_entry(db, Section.subscription, "Twitch", Decimal("10.99")) + updated = service.update_entry(db, entry.id, amount=Decimal("11.99")) + assert updated is not None + assert updated.name == "Twitch" + assert updated.amount == Decimal("11.99") + + +def test_update_entry_notes_set_and_clear(db): + entry = service.add_entry(db, Section.other, "Parking", Decimal("25.00")) + updated = service.update_entry(db, entry.id, notes="work") + assert updated is not None + assert updated.notes == "work" + updated = service.update_entry(db, entry.id, notes="") + assert updated is not None + assert updated.notes is None + service.update_entry(db, entry.id, notes="work again") + updated = service.update_entry(db, entry.id, notes=None) + assert updated is not None + assert updated.notes is None + + +def test_update_entry_all_three_atomic(db): + entry = service.add_entry(db, Section.food, "Groceries", Decimal("400.00")) + updated = service.update_entry( + db, + entry.id, + name="Groceries (Costco)", + amount=Decimal("450.00"), + notes="weekly run", + ) + assert updated is not None + assert updated.name == "Groceries (Costco)" + assert updated.amount == Decimal("450.00") + assert updated.notes == "weekly run" + + +def test_update_entry_notes_untouched_when_sentinel(db): + entry = service.add_entry( + db, Section.other, "Gift", Decimal("25.00"), notes="birthday" + ) + updated = service.update_entry(db, entry.id, amount=Decimal("30.00")) + assert updated is not None + assert updated.notes == "birthday" + + +def test_update_entry_missing_returns_none(db): + assert service.update_entry(db, 9999, name="Whatever") is None + + +def test_update_entry_does_not_mutate_existing_month_snapshot(db): + from quartermaster import month_service + entry = service.add_entry( + db, Section.subscription, "Twitch", Decimal("10.99") + ) + month = month_service.create_month(db, "2026-04") + me = next(e for e in month.entries if e.source_entry_id == entry.id) + assert me.planned == Decimal("10.99") + assert me.origin_planned == Decimal("10.99") + assert me.name == "Twitch" + assert me.origin_name == "Twitch" + + service.update_entry( + db, entry.id, name="Twitch Prime", amount=Decimal("11.99") + ) + db.refresh(me) + assert me.planned == Decimal("10.99") + assert me.origin_planned == Decimal("10.99") + assert me.name == "Twitch" + assert me.origin_name == "Twitch" diff --git a/tests/test_template_edit_isolation.py b/tests/test_template_edit_isolation.py new file mode 100644 index 0000000..3cdb224 --- /dev/null +++ b/tests/test_template_edit_isolation.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from decimal import Decimal + +from quartermaster import month_service, service +from quartermaster.models import Section + + +def test_template_edit_does_not_mutate_existing_month_and_applies_to_next(db): + # seed the template + twitch = service.add_entry( + db, Section.subscription, "Twitch", Decimal("10.99") + ) + + # create April 2026 (snapshots the current template) + april = month_service.create_month(db, "2026-04") + april_twitch = next( + e for e in april.entries if e.source_entry_id == twitch.id + ) + assert april_twitch.planned == Decimal("10.99") + assert april_twitch.origin_planned == Decimal("10.99") + assert april_twitch.name == "Twitch" + assert april_twitch.origin_name == "Twitch" + + # edit the template + service.update_entry( + db, twitch.id, name="Twitch Prime", amount=Decimal("11.99") + ) + + # april is untouched + db.refresh(april_twitch) + assert april_twitch.planned == Decimal("10.99") + assert april_twitch.origin_planned == Decimal("10.99") + assert april_twitch.name == "Twitch" + assert april_twitch.origin_name == "Twitch" + + # creating May 2026 picks up the new values + may = month_service.create_month(db, "2026-05") + may_twitch = next(e for e in may.entries if e.source_entry_id == twitch.id) + assert may_twitch.planned == Decimal("11.99") + assert may_twitch.origin_planned == Decimal("11.99") + assert may_twitch.name == "Twitch Prime" + assert may_twitch.origin_name == "Twitch Prime"