"""Removing a holding, and what stops it. The requirement: you must be able to remove a holding added by accident, it must not have any trades to be removed … if I hover over the delete button it should tell me why." Two things make this more than a delete button. * **Instruments are shared.** The list, and the price history with it, belongs to everyone using the tracker. So removal *deactivates* rather than deletes, and the "is anything recorded against it" question has to be asked of every portfolio — not just the one asking. * **But the answer must not leak.** The panel lists the asking portfolio's own trades, with links; anything belonging to somebody else is counted and never shown. """ from __future__ import annotations import re from sqlalchemy import select import factories as fac from app.models import Instrument from test_routes import bind_to_only_portfolio, make_login, session_csrf HTML = {"accept": "text/html"} def _page(client) -> str: response = client.get("/holdings", headers=HTML) assert response.status_code == 200 return response.text def test_an_untraded_instrument_offers_a_remove_button(client, session_factory): make_login(client, session_factory) with session_factory() as s: fac.hold(s, fac.make_instrument(s, "SPARE")) s.commit() page = _page(client) assert re.search(r'action="/holdings/\d+/remove"', page), "no Remove control" def test_removing_one_asks_first_and_says_what_goes(client, session_factory): """It went at a click, its note and reinvest setting with it.""" make_login(client, session_factory) with session_factory() as s: fac.hold(s, fac.make_instrument(s, "SPARE")) s.commit() page = _page(client) assert re.search(r']*action="/holdings/\d+/remove"[^>]*data-confirm="Remove SPARE ' r'from this portfolio\? Its note and reinvest setting go with it\."', page) def test_removing_it_stops_it_being_tracked(client, session_factory): make_login(client, session_factory) with session_factory() as s: inst = fac.make_instrument(s, "SPARE") fac.hold(s, inst) s.commit() instrument_id = inst.id client.post(f"/holdings/{instrument_id}/remove", data={"_csrf": session_csrf(session_factory)}, headers=HTML, follow_redirects=True) with session_factory() as s: assert s.get(Instrument, instrument_id).active is False def test_it_is_deactivated_rather_than_deleted(client, session_factory): """The row and its price history are shared. Deleting would take that history from anybody who later re-adds the same ticker.""" make_login(client, session_factory) with session_factory() as s: inst = fac.make_instrument(s, "SPARE") fac.hold(s, inst) fac.add_price(s, inst, "2026-01-05", "10.00") s.commit() instrument_id = inst.id client.post(f"/holdings/{instrument_id}/remove", data={"_csrf": session_csrf(session_factory)}, headers=HTML, follow_redirects=True) with session_factory() as s: assert s.get(Instrument, instrument_id) is not None, "the row was deleted" from app.models import Price assert s.scalars(select(Price).where( Price.instrument_id == instrument_id)).all(), "price history was lost" def test_an_instrument_with_trades_shows_what_is_blocking_it(client, session_factory): """Not a disabled button — one that opens and says why, with a link to each thing in the way. A `disabled` button cannot be hovered or focused in most browsers, so its explanation is unreachable exactly when it is wanted.""" make_login(client, session_factory) with session_factory() as s: bind_to_only_portfolio(s) inst = fac.make_instrument(s, "HELD") fac.hold(s, inst) fac.add_trade(s, inst, "2026-01-05", "buy", 10, "5.00") s.commit() page = _page(client) row = page[page.index("HELD"):] assert "blockedby" in row, "no explanation panel" assert re.search(r'/trade/\d+/edit\?return=/holdings', row), ( "the panel does not link to the trade in the way") def test_a_reinvested_distribution_in_the_way_links_to_where_it_is_edited( client, session_factory): """Its units are a trade, but one edited through its distribution so the two stay in step (decisions.md #91). The link went to the trade's own edit page, which refuses it, so the one thing in the way could not be reached from the panel.""" make_login(client, session_factory) with session_factory() as s: bind_to_only_portfolio(s) inst = fac.make_instrument(s, "HELD") fac.hold(s, inst) bought = fac.add_trade(s, inst, "2026-01-05", "buy", 10, "5.00") reinvested, dividend = fac.add_drp(s, inst, "2026-02-05", "5.00", 1, "5.00") s.commit() ids = bought.id, reinvested.id, dividend.id page = _page(client) links = re.findall(r'href="(/(?:trade|dividend)/\d+/edit\?return=/holdings)"', page) assert links == [f"/dividend/{ids[2]}/edit?return=/holdings", f"/trade/{ids[0]}/edit?return=/holdings"] assert client.get(links[0], headers=HTML).status_code == 200 def test_the_blocked_row_offers_no_remove_form(client, session_factory): make_login(client, session_factory) with session_factory() as s: bind_to_only_portfolio(s) inst = fac.make_instrument(s, "HELD") fac.hold(s, inst) fac.add_trade(s, inst, "2026-01-05", "buy", 10, "5.00") s.commit() instrument_id = inst.id page = _page(client) assert f'action="/holdings/{instrument_id}/remove"' not in page def test_the_route_refuses_it_too_not_just_the_page(client, session_factory): """The button being absent is presentation. A stale tab, or anybody posting directly, must hit the same wall.""" make_login(client, session_factory) with session_factory() as s: bind_to_only_portfolio(s) inst = fac.make_instrument(s, "HELD") fac.hold(s, inst) fac.add_trade(s, inst, "2026-01-05", "buy", 10, "5.00") s.commit() instrument_id = inst.id resp = client.post(f"/holdings/{instrument_id}/remove", data={"_csrf": session_csrf(session_factory)}, headers=HTML) assert resp.status_code == 409 with session_factory() as s: assert s.get(Instrument, instrument_id).active is True def test_a_dividend_blocks_it_as_well_as_a_trade(client, session_factory): """A dividend refers to the instrument too, and leaving one pointing at something no longer tracked is the same broken ledger.""" make_login(client, session_factory) with session_factory() as s: bind_to_only_portfolio(s) inst = fac.make_instrument(s, "PAYER") fac.hold(s, inst) fac.add_dividend(s, inst, "2026-02-01", "12.34") s.commit() instrument_id = inst.id resp = client.post(f"/holdings/{instrument_id}/remove", data={"_csrf": session_csrf(session_factory)}, headers=HTML) assert resp.status_code == 409 def test_the_page_shows_a_price_even_for_something_no_longer_held(client, session_factory): """The reason: a position you exited is still one you may be watching, and the dashboard deliberately hides it.""" make_login(client, session_factory) with session_factory() as s: inst = fac.make_instrument(s, "GONE") fac.hold(s, inst) fac.add_price(s, inst, "2026-01-05", "41.00") fac.add_price(s, inst, "2026-02-05", "42.50") s.commit() page = _page(client) assert "42.50" in page, "the latest close is not shown" assert "41.00" not in page, "an older close is being shown instead of the latest"