"""Editing and deleting what's already in the ledger. The ledger is not append-only, and three things depended on it being so without saying so: the series cache fingerprinted on row counts, the FIFO matcher assumed parcels only ever arrived, and the balance guard only ever validated a row being added. Editing breaks all three quietly — the numbers stay plausible, they are just wrong — so the tests here lean on the consequences rather than on the routes returning 303. Four groups: * **the guard** — a change is checked against the WHOLE timeline, not just the row being changed, and the refusal names the trade that has to move first (a bare "would go negative" leaves you hunting through six years of ledger for the one that broke); * **the cache** — an edit that changes no row count still invalidates every derived number, which is what `updated_at` exists for; * **linked rows** — a DRP trade and its dividend are one event; a planned purchase outlives the trade it created; * **permissions** — a viewer has no way to do any of it. """ from __future__ import annotations import datetime as dt import re from decimal import Decimal import pytest from freezegun import freeze_time from sqlalchemy import select import factories as fac from app import queries, tenancy from app.models import Dividend, Instrument, PlannedPurchase, PortfolioMember, Trade from test_routes import bind_to_only_portfolio, make_login, reading, session_csrf HTML = {"accept": "text/html"} TODAY = "2026-08-02" @pytest.fixture def ledger(client, session_factory): """A signed-in owner holding 100 ACME bought in March, added as "+ Add an instrument" adds it, so deleting the trade leaves the holding on the list.""" make_login(client, session_factory) with session_factory() as s: bind_to_only_portfolio(s) acme = fac.make_instrument(s, "ACME", name="Acme Industries") fac.hold(s, acme) buy = fac.add_trade(s, acme, "2026-03-02", "buy", 100, "5.00", brokerage="9.50") s.commit() return {"acme_id": acme.id, "buy_id": buy.id} def edit_trade(client, session_factory, trade_id, **overrides): data = { "type": "buy", "trade_date": "2026-03-02", "quantity": "100", "unit_price": "5.00", "brokerage": "9.50", "fx_rate": "", "note": "", "_csrf": session_csrf(session_factory), } data.update(overrides) return client.post( f"/trade/{trade_id}/edit", data=data, headers=HTML, follow_redirects=False ) def delete_trade(client, session_factory, trade_id): return client.post( f"/trade/{trade_id}/delete", data={"_csrf": session_csrf(session_factory)}, headers=HTML, follow_redirects=False, ) # --------------------------------------------------------------------------- # # Editing a trade # --------------------------------------------------------------------------- # @freeze_time(TODAY) def test_the_edit_form_is_prefilled_from_the_row(client, session_factory, ledger): resp = client.get(f"/trade/{ledger['buy_id']}/edit", headers=HTML) assert resp.status_code == 200 assert 'value="2026-03-02"' in resp.text assert 'value="100"' in resp.text assert 'value="5"' in resp.text # The instrument is fixed on edit — moving a trade between instruments is a # delete and a re-record, not a field. (The picker's *id* still appears in # the shared script, which no-ops when the select isn't there.) assert 'name="instrument_id"' not in resp.text assert "ACME — Acme Industries" in resp.text @freeze_time(TODAY) def test_editing_changes_the_stored_trade(client, session_factory, ledger): resp = edit_trade( client, session_factory, ledger["buy_id"], quantity="120", unit_price="5.50" ) assert resp.status_code == 303 assert resp.headers["location"] == "/holding/ACME" with reading(session_factory) as s: trade = s.get(Trade, ledger["buy_id"]) assert trade.quantity == Decimal("120") assert trade.unit_price == Decimal("5.50") @freeze_time(TODAY) def test_an_edit_can_flip_a_buy_to_a_sell(client, session_factory, ledger): """Recording the wrong side is an ordinary mistake, and the timeline still has to survive the correction — so this one is refused: nothing was held before the buy that is being turned into a sell.""" resp = edit_trade(client, session_factory, ledger["buy_id"], type="sell") assert resp.status_code == 303 assert "error=" in resp.headers["location"] with reading(session_factory) as s: assert s.get(Trade, ledger["buy_id"]).type == "buy" @freeze_time(TODAY) def test_the_edit_form_rejects_a_future_date(client, session_factory, ledger): resp = edit_trade(client, session_factory, ledger["buy_id"], trade_date="2027-01-01") assert "future" in resp.headers["location"] with reading(session_factory) as s: assert s.get(Trade, ledger["buy_id"]).date == dt.date(2026, 3, 2) @freeze_time(TODAY) def test_the_edit_form_rejects_nonsense_numbers(client, session_factory, ledger): resp = edit_trade(client, session_factory, ledger["buy_id"], quantity="not-a-number") assert "error=" in resp.headers["location"] with reading(session_factory) as s: assert s.get(Trade, ledger["buy_id"]).quantity == Decimal("100") @freeze_time(TODAY) @pytest.mark.parametrize( "overrides, expected", [ ({"type": "drp"}, "Choose+buy+or+sell"), ({"quantity": "0"}, "Units+must+be+greater+than+zero"), # Zero price is ACCEPTED now (a bonus issue, a reward-plan grant), so # this pins the remaining floor rather than the old combined one — # tests/test_trade_prefill.py holds the accepting half. ({"unit_price": "-1"}, "Price+can%27t+be+negative"), ({"brokerage": "-5"}, "can%27t+be+negative"), ({"fx_rate": "0"}, "FX+must+be+positive"), ({"set_time": "1", "trade_time": "half+past+ten"}, "isn%27t+a+time"), # Not figures at all, and figures too big for their column. NaN used to # raise out of the `<= 0` check below it, Infinity was saved, and # 1E+999999 reads back from SQLite as Infinity. ({"quantity": "NaN"}, "Units%3A+%27NaN%27+is+not+a+number"), ({"quantity": "Infinity"}, "Units%3A+%27Infinity%27+is+not+a+number"), ({"quantity": "1E+999999"}, "Units%3A+%271E%2B999999%27+is+too+large"), ({"quantity": "1000000000000"}, "is+too+large"), ({"unit_price": "NaN"}, "Price%3A+%27NaN%27+is+not+a+number"), ({"unit_price": "1000000000000"}, "Price%3A"), ({"brokerage": "Infinity"}, "Brokerage%3A+%27Infinity%27+is+not+a+number"), ({"fx_rate": "NaN"}, "FX+rate%3A+%27NaN%27+is+not+a+number"), ], ids=["type", "zero-units", "negative-price", "negative-brokerage", "zero-fx", "bad-time", "nan-units", "infinite-units", "huge-units", "units-at-the-limit", "nan-price", "huge-price", "infinite-brokerage", "nan-fx"], ) def test_the_edit_form_validates_like_the_create_form( client, session_factory, ledger, overrides, expected ): """Edit runs the same gauntlet as create. Two forms with one set of rules between them is how the strict one quietly becomes the lenient one.""" resp = edit_trade(client, session_factory, ledger["buy_id"], **overrides) assert expected in resp.headers["location"] with reading(session_factory) as s: trade = s.get(Trade, ledger["buy_id"]) assert trade.quantity == Decimal("100") assert trade.type == "buy" @freeze_time(TODAY) def test_setting_a_time_on_an_edit_stores_it(client, session_factory, ledger): resp = edit_trade( client, session_factory, ledger["buy_id"], set_time="1", trade_time="14:30" ) assert resp.headers["location"] == "/holding/ACME" with reading(session_factory) as s: assert s.get(Trade, ledger["buy_id"]).time == dt.time(14, 30) @freeze_time(TODAY) def test_unticking_the_time_puts_the_trade_back_at_the_open(client, session_factory, ledger): """Unticking has to mean something — otherwise a time set by mistake can never be taken off again.""" edit_trade(client, session_factory, ledger["buy_id"], set_time="1", trade_time="14:30") edit_trade(client, session_factory, ledger["buy_id"], set_time="", trade_time="14:30") with reading(session_factory) as s: assert s.get(Trade, ledger["buy_id"]).time == dt.time(10, 0) def _checkbox(html: str, name: str) -> str: """The one tag with this name, so 'checked' can be asserted on the tag itself rather than anywhere in the page.""" match = re.search(rf"]*name=\"{name}\"[^>]*>", html) assert match, f"no {name} input in the page" return match.group(0) @freeze_time(TODAY) def test_the_edit_form_prefills_a_time_that_was_set(client, session_factory, ledger): edit_trade(client, session_factory, ledger["buy_id"], set_time="1", trade_time="14:30") resp = client.get(f"/trade/{ledger['buy_id']}/edit", headers=HTML) assert 'value="14:30"' in resp.text assert "checked" in _checkbox(resp.text, "set_time") @freeze_time(TODAY) def test_a_trade_at_the_open_comes_back_with_the_box_unticked(client, session_factory, ledger): """A trade nobody set a time on shouldn't look like one somebody did.""" resp = client.get(f"/trade/{ledger['buy_id']}/edit", headers=HTML) assert "checked" not in _checkbox(resp.text, "set_time") @freeze_time(TODAY) def test_a_trade_in_another_portfolio_is_not_found(client, session_factory, ledger): """`db.get` doesn't go through the tenancy filter, so the route checks the owner itself. Without that check, an id from someone else's ledger would open in the edit form.""" with session_factory() as s: other = fac.make_portfolio(s, "Someone else") acme = s.get(Instrument, ledger["acme_id"]) theirs = fac.add_trade( s, acme, "2026-04-01", "buy", 5, "5.00", portfolio_id=other.id ) s.commit() theirs_id = theirs.id assert client.get(f"/trade/{theirs_id}/edit", headers=HTML).status_code == 404 assert delete_trade(client, session_factory, theirs_id).status_code == 404 # --------------------------------------------------------------------------- # # The guard: the whole timeline, and a message that names the offender # --------------------------------------------------------------------------- # @freeze_time(TODAY) def test_cutting_an_old_buy_below_a_later_sell_is_refused(client, session_factory, ledger): """The case the plan was written around: reducing a buy from three months ago strands a sell that was fine until now. Validating only the edited row would let this through and break the FY report.""" with session_factory() as s: bind_to_only_portfolio(s) acme = s.get(Trade, ledger["buy_id"]).instrument fac.add_trade(s, acme, "2026-05-10", "sell", 80, "6.00") s.commit() resp = edit_trade(client, session_factory, ledger["buy_id"], quantity="50") assert resp.status_code == 303 location = resp.headers["location"] # The message has to identify the sell, not just say "negative". assert "sell+of+80+units" in location assert "2026-05-10" in location assert "Edit+or+delete+that+sell+first" in location with reading(session_factory) as s: assert s.get(Trade, ledger["buy_id"]).quantity == Decimal("100") @freeze_time(TODAY) def test_moving_a_buy_later_than_its_sell_is_refused(client, session_factory, ledger): """A date move breaks the timeline just as a quantity cut does — the units arrive after they were sold.""" with session_factory() as s: bind_to_only_portfolio(s) acme = s.get(Trade, ledger["buy_id"]).instrument fac.add_trade(s, acme, "2026-05-10", "sell", 100, "6.00") s.commit() resp = edit_trade(client, session_factory, ledger["buy_id"], trade_date="2026-06-01") assert "error=" in resp.headers["location"] with reading(session_factory) as s: assert s.get(Trade, ledger["buy_id"]).date == dt.date(2026, 3, 2) @freeze_time(TODAY) def test_an_edit_that_keeps_the_timeline_valid_is_accepted(client, session_factory, ledger): """The mirror of the two above: the guard has to allow the ordinary case, or it is just a wall.""" with session_factory() as s: bind_to_only_portfolio(s) acme = s.get(Trade, ledger["buy_id"]).instrument fac.add_trade(s, acme, "2026-05-10", "sell", 80, "6.00") s.commit() resp = edit_trade(client, session_factory, ledger["buy_id"], quantity="90") assert resp.headers["location"] == "/holding/ACME" with reading(session_factory) as s: assert s.get(Trade, ledger["buy_id"]).quantity == Decimal("90") def test_the_breach_message_names_the_later_trade(): """Unit-level, so the wording is pinned somewhere a route test won't obscure it. This is the copy agreed in the plan.""" inst = type("I", (), {"ticker": "ALPHA", "currency": "AUD"})() buy = Trade(id=1, date=dt.date(2024, 1, 5), type="buy", quantity=Decimal("100"), unit_price=Decimal("90"), brokerage=Decimal("0")) sell = Trade(id=2, date=dt.date(2024, 3, 10), type="sell", quantity=Decimal("100"), unit_price=Decimal("95"), brokerage=Decimal("0")) candidate = Trade(id=1, date=dt.date(2024, 1, 5), type="buy", quantity=Decimal("50"), unit_price=Decimal("90"), brokerage=Decimal("0")) breach = queries.balance_breach([buy, sell], candidate, exclude_ids={1}) message = queries.breach_message(inst.ticker, breach) assert breach.trade is sell assert breach.balance == Decimal("-50") assert message == ( "That change would leave ALPHA at -50 units from 2024-03-10: the sell of " "100 units on 2024-03-10 would then be more than the 50 units held. " "Edit or delete that sell first." ) def test_the_breach_message_is_different_when_the_new_row_is_the_problem(): """Telling someone to "edit or delete that sell first" when the sell IS what they are typing would be nonsense.""" buy = Trade(id=1, date=dt.date(2024, 1, 5), type="buy", quantity=Decimal("10"), unit_price=Decimal("4"), brokerage=Decimal("0")) candidate = Trade(date=dt.date(2024, 7, 1), type="sell", quantity=Decimal("25"), unit_price=Decimal("5"), brokerage=Decimal("0")) breach = queries.balance_breach([buy], candidate) assert breach.is_candidate assert queries.breach_message("ACME", breach) == ( "That sell of 25 units is more than the 10 units of ACME held on " "2024-07-01 — check the date and units against the ledger." ) # --------------------------------------------------------------------------- # # Deleting # --------------------------------------------------------------------------- # @freeze_time(TODAY) def test_deleting_a_trade_removes_it(client, session_factory, ledger): resp = delete_trade(client, session_factory, ledger["buy_id"]) assert resp.headers["location"] == "/holding/ACME" with reading(session_factory) as s: assert s.get(Trade, ledger["buy_id"]) is None @freeze_time(TODAY) def test_deleting_a_buy_that_a_later_sell_depends_on_is_refused( client, session_factory, ledger ): with session_factory() as s: bind_to_only_portfolio(s) acme = s.get(Trade, ledger["buy_id"]).instrument fac.add_trade(s, acme, "2026-05-10", "sell", 100, "6.00") s.commit() resp = delete_trade(client, session_factory, ledger["buy_id"]) # Back to the ledger, where the sell it named can be dealt with. assert resp.headers["location"].startswith("/holding/ACME?error=") with reading(session_factory) as s: assert s.get(Trade, ledger["buy_id"]) is not None @freeze_time(TODAY) def test_deleting_a_trade_keeps_the_planned_purchase_as_history( client, session_factory, ledger ): """The buy happened on schedule; deleting the trade doesn't unschedule it. The row stays, its link goes, and it says why — otherwise the rotation would silently re-offer a slot that was already filled.""" with session_factory() as s: bind_to_only_portfolio(s) from app import tenancy s.add( tenancy.owned( s, PlannedPurchase( due_date=dt.date(2026, 3, 2), instrument_id=ledger["acme_id"], status="done", trade_id=ledger["buy_id"], ), ) ) s.commit() delete_trade(client, session_factory, ledger["buy_id"]) with reading(session_factory) as s: purchase = s.scalars(select(PlannedPurchase)).one() assert purchase.trade_id is None assert purchase.status == "done" assert "trade removed" in purchase.note # --------------------------------------------------------------------------- # # DRP: the trade and the dividend are one event # --------------------------------------------------------------------------- # @pytest.fixture def with_drp(client, session_factory, ledger): with session_factory() as s: bind_to_only_portfolio(s) acme = s.get(Trade, ledger["buy_id"]).instrument trade, dividend = fac.add_drp(s, acme, "2026-04-15", "24.00", 4, "6.00") s.commit() return {**ledger, "drp_id": trade.id, "dividend_id": dividend.id} @freeze_time(TODAY) def test_a_drp_trade_cannot_be_edited_on_its_own(client, session_factory, with_drp): """Editing the units without the cash would leave the two halves of one statement line disagreeing, and the dividend income wrong.""" resp = client.get(f"/trade/{with_drp['drp_id']}/edit", headers=HTML) assert resp.status_code == 409 assert "through its dividend" in resp.text @freeze_time(TODAY) def test_a_drp_trade_cannot_be_deleted_on_its_own(client, session_factory, with_drp): resp = delete_trade(client, session_factory, with_drp["drp_id"]) assert resp.status_code == 409 with reading(session_factory) as s: assert s.get(Trade, with_drp["drp_id"]) is not None @freeze_time(TODAY) def test_the_ledger_points_a_drp_row_at_its_dividend(client, session_factory, with_drp): """The affordance has to match the rule: a DRP row's edit link goes to the dividend, or people meet the 409 by following the obvious path.""" resp = client.get("/holding/ACME", headers=HTML) assert f"/dividend/{with_drp['dividend_id']}/edit" in resp.text assert f"/trade/{with_drp['drp_id']}/edit" not in resp.text @freeze_time(TODAY) def test_the_dividend_form_shows_units_only_for_a_reinvested_one( client, session_factory, with_drp, ledger ): """A cash distribution has no units to edit, and offering the fields would invite someone to invent a DRP that never happened.""" with session_factory() as s: bind_to_only_portfolio(s) acme = s.get(Instrument, ledger["acme_id"]) cash_only = fac.add_dividend(s, acme, "2026-05-01", "40.00") s.commit() cash_id = cash_only.id reinvested = client.get(f"/dividend/{with_drp['dividend_id']}/edit", headers=HTML) plain = client.get(f"/dividend/{cash_id}/edit", headers=HTML) assert reinvested.status_code == 200 assert "Units reinvested" in reinvested.text assert 'value="24"' in reinvested.text # the cash assert plain.status_code == 200 assert "Units reinvested" not in plain.text @freeze_time(TODAY) def test_a_dividend_in_another_portfolio_is_not_found(client, session_factory, ledger): with session_factory() as s: other = fac.make_portfolio(s, "Someone else") acme = s.get(Instrument, ledger["acme_id"]) theirs = fac.add_dividend(s, acme, "2026-04-01", "5.00", portfolio_id=other.id) s.commit() theirs_id = theirs.id assert client.get(f"/dividend/{theirs_id}/edit", headers=HTML).status_code == 404 @freeze_time(TODAY) @pytest.mark.parametrize( "overrides, expected", [ ({"cash_amount": "lots"}, "Cash%3A+%27lots%27+is+not+a+number"), ({"cash_amount": "0"}, "more+than+zero"), ({"franking_credits": "-1"}, "can%27t+be+negative"), ({"div_date": "2027-01-01"}, "future"), ({"quantity": "many"}, "Units%3A+%27many%27+is+not+a+number"), ({"quantity": "0"}, "greater+than+zero"), ({"cash_amount": "NaN"}, "Cash%3A+%27NaN%27+is+not+a+number"), ({"cash_amount": "1E+999999"}, "Cash%3A+%271E%2B999999%27+is+too+large"), ({"franking_credits": "Infinity"}, "Franking+credits%3A+%27Infinity%27+is+not+a+number"), ({"quantity": "NaN"}, "Units%3A+%27NaN%27+is+not+a+number"), ({"unit_price": "1E+999999"}, "Price%3A+%271E%2B999999%27+is+too+large"), ], ids=["bad-cash", "zero-cash", "negative-franking", "future", "bad-units", "zero-units", "nan-cash", "huge-cash", "infinite-franking", "nan-units", "huge-price"], ) def test_the_dividend_form_validates( client, session_factory, with_drp, overrides, expected ): data = {"div_date": "2026-04-15", "cash_amount": "24.00", "franking_credits": "", "quantity": "4", "unit_price": "6.00", "note": "", "_csrf": session_csrf(session_factory)} data.update(overrides) resp = client.post( f"/dividend/{with_drp['dividend_id']}/edit", data=data, headers=HTML, follow_redirects=False, ) assert expected in resp.headers["location"] with reading(session_factory) as s: # Nothing moved — and in particular the DRP trade didn't move without # its dividend, which is the failure mode that matters here. assert s.get(Dividend, with_drp["dividend_id"]).cash_amount == Decimal("24.00") assert s.get(Trade, with_drp["drp_id"]).quantity == Decimal("4") @freeze_time(TODAY) def test_growing_a_drp_is_fine_but_shrinking_it_below_a_sell_is_not( client, session_factory, with_drp ): """The reinvested units are checked against the timeline exactly as a trade's are — the dividend form is not a side door around the balance guard.""" with session_factory() as s: bind_to_only_portfolio(s) acme = s.get(Instrument, with_drp["acme_id"]) fac.add_trade(s, acme, "2026-06-01", "sell", 104, "7.00") s.commit() data = {"div_date": "2026-04-15", "cash_amount": "24.00", "franking_credits": "", "quantity": "2", "unit_price": "6.00", "note": "", "_csrf": session_csrf(session_factory)} resp = client.post( f"/dividend/{with_drp['dividend_id']}/edit", data=data, headers=HTML, follow_redirects=False, ) assert "error=" in resp.headers["location"] assert "sell+of+104+units" in resp.headers["location"] with reading(session_factory) as s: assert s.get(Trade, with_drp["drp_id"]).quantity == Decimal("4") @freeze_time(TODAY) def test_editing_a_dividend_moves_both_halves(client, session_factory, with_drp): resp = client.post( f"/dividend/{with_drp['dividend_id']}/edit", data={"div_date": "2026-04-20", "cash_amount": "30.00", "franking_credits": "", "quantity": "5", "unit_price": "6.00", "note": "corrected", "_csrf": session_csrf(session_factory)}, headers=HTML, follow_redirects=False, ) assert resp.headers["location"] == "/holding/ACME" with reading(session_factory) as s: dividend = s.get(Dividend, with_drp["dividend_id"]) trade = s.get(Trade, with_drp["drp_id"]) assert dividend.cash_amount == Decimal("30.00") assert dividend.date == dt.date(2026, 4, 20) # The units followed the cash to the new date. assert trade.quantity == Decimal("5") assert trade.date == dt.date(2026, 4, 20) @freeze_time(TODAY) def test_deleting_a_dividend_takes_its_reinvested_units_with_it( client, session_factory, with_drp ): resp = client.post( f"/dividend/{with_drp['dividend_id']}/delete", data={"_csrf": session_csrf(session_factory)}, headers=HTML, follow_redirects=False, ) assert resp.headers["location"] == "/holding/ACME" with reading(session_factory) as s: assert s.get(Dividend, with_drp["dividend_id"]) is None assert s.get(Trade, with_drp["drp_id"]) is None # The ordinary buy is untouched. assert s.get(Trade, with_drp["buy_id"]) is not None @freeze_time(TODAY) def test_deleting_a_dividend_is_refused_when_its_units_were_sold( client, session_factory, with_drp ): """The reinvested units are real units — if they have been sold, removing them strands the sell.""" with session_factory() as s: bind_to_only_portfolio(s) acme = s.get(Trade, with_drp["buy_id"]).instrument fac.add_trade(s, acme, "2026-06-01", "sell", 104, "7.00") s.commit() resp = client.post( f"/dividend/{with_drp['dividend_id']}/delete", data={"_csrf": session_csrf(session_factory)}, headers=HTML, follow_redirects=False, ) assert resp.headers["location"].startswith("/holding/ACME?error=") with reading(session_factory) as s: assert s.get(Dividend, with_drp["dividend_id"]) is not None assert s.get(Trade, with_drp["drp_id"]) is not None @freeze_time(TODAY) def test_a_plain_dividend_edits_without_units(client, session_factory, ledger): with session_factory() as s: bind_to_only_portfolio(s) acme = s.get(Trade, ledger["buy_id"]).instrument dividend = fac.add_dividend(s, acme, "2026-04-15", "40.00") s.commit() dividend_id = dividend.id resp = client.post( f"/dividend/{dividend_id}/edit", data={"div_date": "2026-04-15", "cash_amount": "45.00", "franking_credits": "19.28", "note": "", "_csrf": session_csrf(session_factory)}, headers=HTML, follow_redirects=False, ) assert resp.headers["location"] == "/holding/ACME" with reading(session_factory) as s: dividend = s.get(Dividend, dividend_id) assert dividend.cash_amount == Decimal("45.00") assert dividend.franking_credits == Decimal("19.28") # --------------------------------------------------------------------------- # # Permissions # --------------------------------------------------------------------------- # @freeze_time(TODAY) def test_a_viewer_cannot_edit_or_delete(client, session_factory, ledger): """Viewers read everything and change nothing — including through a URL they were never shown.""" with reading(session_factory) as s: s.scalars(select(PortfolioMember)).one().role = "viewer" s.commit() assert client.get(f"/trade/{ledger['buy_id']}/edit", headers=HTML).status_code == 403 assert delete_trade(client, session_factory, ledger["buy_id"]).status_code == 403 with reading(session_factory) as s: assert s.get(Trade, ledger["buy_id"]) is not None @freeze_time(TODAY) def test_the_ledger_shows_no_edit_links_to_a_viewer(client, session_factory, ledger): with reading(session_factory) as s: s.scalars(select(PortfolioMember)).one().role = "viewer" s.commit() resp = client.get("/holding/ACME", headers=HTML) assert resp.status_code == 200 assert "/edit" not in resp.text @freeze_time(TODAY) def test_edit_and_delete_need_a_csrf_token(client, session_factory, ledger): for url in (f"/trade/{ledger['buy_id']}/edit", f"/trade/{ledger['buy_id']}/delete"): resp = client.post(url, data={"type": "buy", "trade_date": "2026-03-02", "quantity": "1", "unit_price": "1"}, headers=HTML, follow_redirects=False) assert resp.status_code == 403, url with reading(session_factory) as s: assert s.get(Trade, ledger["buy_id"]).quantity == Decimal("100") # Going back where you came from: carried through the form, never read from # `Referer` — decisions.md #40. def test_the_edit_page_carries_a_return_target(client, session_factory, ledger): trade_id = ledger["buy_id"] page = client.get(f"/trade/{trade_id}/edit?return=/holdings", headers=HTML).text assert 'name="return_to" value="/holdings"' in page def test_deleting_goes_back_there(client, session_factory, ledger): trade_id = ledger["buy_id"] resp = client.post(f"/trade/{trade_id}/delete", data={"_csrf": session_csrf(session_factory), "return_to": "/holdings"}, headers=HTML, follow_redirects=False) assert resp.headers["location"] == "/holdings" def test_without_one_it_falls_back_to_the_instrument(client, session_factory, ledger): """Not the dashboard: the instrument's own page is nearer to where they were, and it is where the rest of that trade's history is.""" trade_id = ledger["buy_id"] resp = client.post(f"/trade/{trade_id}/delete", data={"_csrf": session_csrf(session_factory)}, headers=HTML, follow_redirects=False) assert resp.headers["location"].startswith("/holding/") @pytest.mark.parametrize("hostile", [ "//evil.test", # protocol-relative: a browser leaves the site "/\\evil.test", # some browsers treat this the same way "https://evil.test", "javascript:alert(1)", "", ]) def test_a_hostile_return_target_is_refused(client, session_factory, ledger, hostile): """It ends up in a Location header, so it is an open-redirect surface. "Starts with a slash" is not enough — `//evil.test` does too.""" trade_id = ledger["buy_id"] resp = client.post(f"/trade/{trade_id}/delete", data={"_csrf": session_csrf(session_factory), "return_to": hostile}, headers=HTML, follow_redirects=False) assert resp.headers["location"].startswith("/holding/"), ( f"{hostile!r} was honoured as a redirect target") # --------------------------------------------------------------------------- # # Every field an edit carries is saved, and only the row named is touched # --------------------------------------------------------------------------- # # A mutation run removed the lines that save an edited trade's date, type, # brokerage, rate and note, and an edited dividend's note and reinvestment # price, and nothing failed: the edit tests changed the units or the cash. It # also let the "edit it through its dividend" check find any dividend at all, # and let deleting one trade unlink every planned purchase. @freeze_time(TODAY) def test_an_edited_trade_keeps_every_field(client, session_factory): make_login(client, session_factory) with session_factory() as s: bind_to_only_portfolio(s) nova = fac.make_instrument(s, "NOVA", exchange="NASDAQ", currency="USD") fac.hold(s, nova) fac.add_trade(s, nova, "2026-03-02", "buy", 100, "5.00", fx_rate="1.50") second = fac.add_trade(s, nova, "2026-03-05", "buy", 10, "5.00", fx_rate="1.50") s.commit() trade_id = second.id resp = client.post(f"/trade/{trade_id}/edit", headers=HTML, follow_redirects=False, data={ "type": "sell", "trade_date": "2026-03-06", "set_time": "1", "trade_time": "14:30", "quantity": "10", "unit_price": "7.00", "brokerage": "4.95", "fx_rate": "1.6", "note": "edited", "_csrf": session_csrf(session_factory)}) assert resp.status_code == 303, resp.text with reading(session_factory) as s: t = s.get(Trade, trade_id) assert (t.type, t.date.isoformat(), t.time.strftime("%H:%M"), t.quantity, t.unit_price, t.brokerage, t.fx_rate, t.note) == ( "sell", "2026-03-06", "14:30", Decimal(10), Decimal(7), Decimal("4.95"), Decimal("1.6"), "edited") @freeze_time(TODAY) def test_an_edited_reinvestment_keeps_its_price_and_note(client, session_factory, with_drp): client.post(f"/dividend/{with_drp['dividend_id']}/edit", headers=HTML, follow_redirects=False, data={ "div_date": "2026-04-15", "cash_amount": "24.00", "franking_credits": "", "quantity": "4", "unit_price": "6.10", "note": "registry corrected", "_csrf": session_csrf(session_factory)}) with reading(session_factory) as s: assert s.get(Trade, with_drp["drp_id"]).unit_price == Decimal("6.10") assert s.get(Dividend, with_drp["dividend_id"]).note == "registry corrected" @freeze_time(TODAY) def test_a_trade_edits_while_the_holding_has_cash_dividends(client, session_factory, ledger): with session_factory() as s: bind_to_only_portfolio(s) fac.add_dividend(s, s.get(Trade, ledger["buy_id"]).instrument, "2026-04-15", "40.00") s.commit() resp = edit_trade(client, session_factory, ledger["buy_id"], quantity="120") assert resp.status_code == 303 with reading(session_factory) as s: assert s.get(Trade, ledger["buy_id"]).quantity == 120 @freeze_time(TODAY) def test_deleting_a_planned_buy_unlinks_only_that_purchase(client, session_factory, ledger): from app.models import InvestmentPlan, PlannedPurchase with session_factory() as s: bind_to_only_portfolio(s) acme = s.get(Trade, ledger["buy_id"]).instrument plan = tenancy.owned(s, InvestmentPlan(name="Plan", interval_days=28)) s.add(plan) s.flush() other = fac.add_trade(s, acme, "2026-03-09", "buy", 10, "5.00") for trade in (s.get(Trade, ledger["buy_id"]), other): s.add(tenancy.owned(s, PlannedPurchase(due_date=trade.date, instrument_id=acme.id, status="done", trade_id=trade.id))) s.commit() other_id = other.id delete_trade(client, session_factory, ledger["buy_id"]) with reading(session_factory) as s: links = sorted((p.trade_id or 0) for p in s.scalars(select(PlannedPurchase))) assert links == [0, other_id]