"""Choosing which columns the holdings table shows. Three things here are guarantees rather than conveniences, and each has a way of quietly breaking: * **A stored preference must never be what stops the dashboard rendering.** It outlives the column it names — remove a column in a later version and every saved choice still mentions it. * **The choice belongs to the person, not the portfolio.** Two people sharing a portfolio each get their own, which is only observable with two accounts. * **Reset stores NULL, not a copy of the defaults.** The difference is invisible today and decides who a future change to `DEFAULTS` reaches. The registry itself is pure and tested directly. The route tests go through the real form post, because the interesting failure — a disabled checkbox is not submitted, so the locked column disappears on save — lives in the template, not in the Python. """ from __future__ import annotations import re from decimal import Decimal import pytest from freezegun import freeze_time from sqlalchemy import select import factories as fac import fixture_portfolio as ref from app import columns, queries, tenancy from app.models import User from test_routes import PASSWORD, bind_to_only_portfolio, make_login, session_csrf # --------------------------------------------------------------------------- # # The registry # --------------------------------------------------------------------------- # class _FakeHolding: """Just enough of a Holding for the sort-key tests: they are about which attribute is read, not about how one is built.""" def __init__(self, ticker: str, drp: bool = False, name: str | None = "Name"): self.instrument = type("I", (), {"ticker": ticker, "name": name})() self.drp = drp def test_every_column_key_is_unique(): """`BY_KEY` is a dict, so a duplicate key would silently shadow rather than fail — and the shadowed column would still render its header.""" keys = [c.key for c in columns.COLUMNS] assert len(keys) == len(set(keys)) def test_the_defaults_all_exist(): """A typo in `DEFAULTS` would drop a column from every dashboard belonging to somebody who never opened the chooser, with nothing to notice.""" assert set(columns.DEFAULTS) <= set(columns.BY_KEY) def test_no_preference_renders_exactly_the_pre_t19_columns(): """The promise that nobody's dashboard moved. Order included: this is the header row people have been reading. """ rendered = [c.key for c in columns.chosen(None)] assert rendered == [ "ticker", "asset_class", "units", "avg_price", "cost", "price", "value_reporting", "gain_reporting", "day_change", "dividends", ] def test_an_empty_stored_preference_is_treated_as_no_preference(): """`[]` reaches `chosen()` from a row written before `clean()` existed, or from a hand-edited database. A table of nothing is not a useful answer.""" assert [c.key for c in columns.chosen([])] == columns.DEFAULTS def test_columns_render_in_the_order_they_were_chosen(): """**Do not change this back to declaration order.** The argument for declaration order — "a table whose columns move about between sessions is harder to read" — is about *incidental* movement, the table changing shape on its own. It says nothing against an order somebody picked, which is stable by definition. See decisions.md #62. """ backwards = ["dividends", "units", "ticker", "price"] assert [c.key for c in columns.chosen(backwards)] == backwards def test_no_preference_renders_the_defaults_in_the_order_they_are_declared(): assert [c.key for c in columns.chosen(None)] == columns.DEFAULTS def test_a_repeated_key_appears_once(): """A hand-edited row, or a move that raced itself. A column rendered twice is a table whose header row no longer matches its body.""" rendered = [c.key for c in columns.chosen(["ticker", "units", "units", "price"])] assert rendered == ["ticker", "units", "price"] def test_the_locked_column_keeps_the_position_it_was_given(): """Locked means it cannot be turned off, not that it must come first. If somebody puts Ticker in the middle, that is their table.""" rendered = [c.key for c in columns.chosen(["units", "ticker", "price"])] assert rendered == ["units", "ticker", "price"] def test_clean_preserves_the_submitted_order(): """The chooser's checkboxes submit in declaration order, so this only matters for the reordering form — but if `clean` sorted, every move would be undone on the next save.""" assert columns.clean(["price", "ticker", "units"]) == ["price", "ticker", "units"] def test_moving_a_column_up_swaps_it_with_its_neighbour(): assert columns.move(["a", "b", "c"], "b", "up") == ["b", "a", "c"] def test_moving_a_column_down_swaps_it_with_its_neighbour(): assert columns.move(["a", "b", "c"], "b", "down") == ["a", "c", "b"] def test_moving_the_first_column_up_does_nothing(): assert columns.move(["a", "b"], "a", "up") == ["a", "b"] def test_moving_the_last_column_down_does_nothing(): assert columns.move(["a", "b"], "b", "down") == ["a", "b"] def test_moving_a_column_that_is_not_there_does_nothing(): """The stale-form case: two tabs open, one of them saved a different selection. Better a no-op than a 500 or a silent reordering of something else.""" assert columns.move(["a", "b"], "zzz", "up") == ["a", "b"] def test_an_unknown_direction_does_nothing(): assert columns.move(["a", "b"], "a", "sideways") == ["a", "b"] def test_move_does_not_mutate_the_stored_list(): stored = ["a", "b", "c"] columns.move(stored, "b", "up") assert stored == ["a", "b", "c"] def test_every_column_can_be_sorted_by(): """A header with no sort key would be the one that silently does nothing when clicked.""" for column in columns.COLUMNS: assert callable(columns.sort_key(column)), column.key def test_the_ticker_column_sorts_by_its_ticker_not_by_the_holding(): """`value` hands the renderer the whole holding, because the cell is a link plus a currency badge. Sorting on that would compare Holding objects, which raises.""" holding = _FakeHolding(ticker="ALPHA") assert columns.sort_key(columns.BY_KEY["ticker"])(holding) == "ALPHA" def test_the_drp_column_sorts_by_the_flag_not_the_word(): """It renders "yes" or nothing, and nothing reads as blank — which would send every non-reinvesting holding to the bottom in both directions and make one of the two clicks useless.""" on = _FakeHolding(ticker="A", drp=True) off = _FakeHolding(ticker="B", drp=False) getter = columns.sort_key(columns.BY_KEY["drp"]) assert getter(on) is True assert getter(off) is False def test_an_unknown_key_is_dropped_rather_than_breaking_the_page(): """The forward-compatibility case: a preference naming a column this version no longer has must render the rest, not raise.""" rendered = [c.key for c in columns.chosen(["ticker", "units", "column_from_v2"])] assert rendered == ["ticker", "units"] def test_the_locked_column_is_added_back_even_if_the_preference_omits_it(): """`ticker` is the row's identity and the link to its ledger. A stored preference without it — a stale row, a hand-edited value, a client that ignored the hidden input — must not produce anonymous numbers.""" rendered = [c.key for c in columns.chosen(["units", "price"])] assert rendered[0] == "ticker" def test_clean_keeps_a_real_selection(): assert columns.clean(["ticker", "units", "gain_reporting"]) == ["ticker", "units", "gain_reporting"] def test_clean_drops_keys_that_do_not_exist(): """The submitted form is attacker-controlled; only names in the registry reach the database.""" assert columns.clean(["units", "'; drop table", "price"]) == ["units", "price"] def test_clean_falls_back_to_the_defaults_when_nothing_was_ticked(): assert columns.clean([]) == columns.DEFAULTS def test_clean_falls_back_when_only_the_locked_column_was_submitted(): """Untick everything and the hidden input still carries `ticker`, so the submission is not empty — but a table of one identity column and no data is the same broken page. The fallback has to look past the locked ones. """ assert columns.clean(["ticker"]) == columns.DEFAULTS def test_clean_returns_a_copy_of_the_defaults(): """It is stored on a JSON column and mutated nowhere, but returning the module-level list itself would make any future mutation global.""" result = columns.clean([]) result.append("name") assert "name" not in columns.DEFAULTS def test_groups_contain_every_column_exactly_once(): """The chooser renders from `groups()`; a column missing from it is a column nobody can turn on again once they have turned it off.""" grouped = [c.key for _, items in columns.groups() for c in items] assert sorted(grouped) == sorted(c.key for c in columns.COLUMNS) def test_a_group_appears_once_even_if_its_columns_are_not_adjacent(): """`groups()` walks the declaration list and would open a second fieldset with the same legend if a group were ever split — worth pinning, because the fix is to merge and that is easy to lose in a later edit. """ names = [name for name, _ in columns.groups()] assert len(names) == len(set(names)) # --------------------------------------------------------------------------- # # Saving a choice # --------------------------------------------------------------------------- # def _stored(session_factory, email: str) -> list[str] | None: """What the user row actually holds. Not portfolio-scoped, so no binding is needed — which is the point of the column being on `user`.""" with session_factory() as s: return s.scalars(select(User).where(User.email == email)).one().dashboard_columns def test_a_fresh_account_has_no_preference_stored(client, session_factory): """NULL means "never chosen", which is what makes a later change to the defaults reach the people who never expressed an opinion.""" email = make_login(client, session_factory) assert _stored(session_factory, email) is None def test_saving_stores_the_selection_and_returns_to_the_dashboard(client, session_factory): email = make_login(client, session_factory) resp = client.post( "/profile/columns", data={"column": ["ticker", "units", "value_reporting"], "_csrf": session_csrf(session_factory)}, headers={"accept": "text/html"}, follow_redirects=False) assert resp.status_code == 303 assert resp.headers["location"] == "/" assert _stored(session_factory, email) == ["ticker", "units", "value_reporting"] def _holdings_table(page: str) -> str: """Just the holdings table. The dashboard carries other markup that mentions instruments — the Record trade dialog's picker names every one of them — so a whole-page search answers a different question from the one these tests ask. """ found = re.search(r'
| Acme Industries | " in _holdings_table(after) def test_the_locked_column_survives_a_save_made_from_the_real_form(client, session_factory): """The bug the hidden input exists for. A **disabled** checkbox is not submitted, so a form posted exactly as the browser would post it must still carry `ticker` — from the hidden input beside it. Scraping the rendered page rather than hand-writing the field is the whole point: hand-writing it would test the fallback in `chosen()` instead of the template that has to get this right. """ make_login(client, session_factory) # A holding, because the column chooser now lives with the table it # configures: a dashboard with nothing recorded shows the first-run # invitation instead, and offering "Edit columns" for a table that isn't # there was never the point. _one_holding(session_factory) page = client.get("/", headers={"accept": "text/html"}).text # The visible checkbox is disabled — which is precisely what makes it # absent from the submission… checkbox = re.search(r']*value="ticker"[^>]*>', page) assert checkbox is not None assert "disabled" in checkbox.group(0) # …so a hidden input has to carry it. assert '' in page client.post("/profile/columns", data={"column": ["ticker", "gain_reporting"], # as the browser posts it "_csrf": session_csrf(session_factory)}, headers={"accept": "text/html"}) assert "ticker" in _stored(session_factory, "user@example.test") def test_an_empty_submission_falls_back_to_the_defaults(client, session_factory): """Untick the lot and press Save: the page must stay usable rather than rendering an empty table.""" email = make_login(client, session_factory) client.post("/profile/columns", data={"_csrf": session_csrf(session_factory)}, headers={"accept": "text/html"}) assert _stored(session_factory, email) == columns.DEFAULTS def test_saving_without_the_csrf_token_is_refused(client, session_factory): """Every mutating POST carries the session token; this one is no different for being a preference.""" email = make_login(client, session_factory) resp = client.post("/profile/columns", data={"column": ["ticker", "units"]}, headers={"accept": "text/html"}) assert resp.status_code == 403 assert _stored(session_factory, email) is None def test_saving_requires_a_session(client, session_factory): make_login(client, session_factory) token = session_csrf(session_factory) client.cookies.clear() resp = client.post("/profile/columns", data={"column": ["ticker"], "_csrf": token}, headers={"accept": "text/html"}, follow_redirects=False) assert resp.status_code == 303 assert resp.headers["location"].startswith("/login") # --------------------------------------------------------------------------- # # Resetting # --------------------------------------------------------------------------- # def test_reset_writes_null_rather_than_a_copy_of_the_defaults(client, session_factory): """The distinction that matters later: NULL means "tracking the defaults", so changing `DEFAULTS` in a future version reaches this person. A stored copy would freeze today's list on their account forever. """ email = make_login(client, session_factory) client.post("/profile/columns", data={"column": ["ticker", "units"], "_csrf": session_csrf(session_factory)}, headers={"accept": "text/html"}) assert _stored(session_factory, email) == ["ticker", "units"] resp = client.post("/profile/columns/reset", data={"_csrf": session_csrf(session_factory)}, headers={"accept": "text/html"}, follow_redirects=False) assert resp.status_code == 303 assert _stored(session_factory, email) is None def test_reset_without_the_csrf_token_is_refused(client, session_factory): email = make_login(client, session_factory) client.post("/profile/columns", data={"column": ["ticker", "units"], "_csrf": session_csrf(session_factory)}, headers={"accept": "text/html"}) resp = client.post("/profile/columns/reset", headers={"accept": "text/html"}) assert resp.status_code == 403 assert _stored(session_factory, email) == ["ticker", "units"] # --------------------------------------------------------------------------- # # Whose choice is it? # --------------------------------------------------------------------------- # def test_the_choice_follows_the_person_not_the_portfolio(client, session_factory): """Two accounts sharing one portfolio choose independently — the reason the column lives on `user` and not on `portfolio` or on the membership. Both log in through the real form, one after the other, because the whole question is which row the route wrote to. """ from app import auth as auth_mod from app.models import Portfolio first = make_login(client, session_factory, email="first@example.test") with session_factory() as s: portfolio = s.scalars(select(Portfolio)).one() second_user = fac.make_user(s, "second@example.test", password_hash=auth_mod.hash_password(PASSWORD)) fac.add_member(s, portfolio, second_user, role="member") s.commit() client.post("/profile/columns", data={"column": ["ticker", "units"], "_csrf": session_csrf(session_factory)}, headers={"accept": "text/html"}) client.post("/logout", data={"_csrf": session_csrf(session_factory)}, headers={"accept": "text/html"}) client.cookies.clear() client.get("/login", headers={"accept": "text/html"}) client.post("/login", data={"email": "second@example.test", "password": PASSWORD, "_csrf": client.cookies.get(auth_mod.PRE_AUTH_CSRF_COOKIE)}, headers={"accept": "text/html"}, follow_redirects=False) # The second person has expressed no preference and sees the defaults… assert _stored(session_factory, "second@example.test") is None client.post("/profile/columns", data={"column": ["ticker", "dividends"], "_csrf": session_csrf(session_factory)}, headers={"accept": "text/html"}) # …and choosing differently leaves the first person's choice alone. assert _stored(session_factory, "second@example.test") == ["ticker", "dividends"] assert _stored(session_factory, first) == ["ticker", "units"] # --------------------------------------------------------------------------- # # Surviving a stored preference that has gone stale # --------------------------------------------------------------------------- # def test_a_dashboard_renders_with_a_preference_naming_a_removed_column( client, session_factory): """The end-to-end version of the unit test above: a row written by a previous version must not produce a 500 after an upgrade drops a column. """ email = make_login(client, session_factory) _one_holding(session_factory) with session_factory() as s: user = s.scalars(select(User).where(User.email == email)).one() user.dashboard_columns = ["ticker", "units", "column_removed_in_v2"] s.commit() resp = client.get("/", headers={"accept": "text/html"}) assert resp.status_code == 200 assert ">Units<" in _header_row(resp.text) assert "/holding/ACME" in resp.text # --------------------------------------------------------------------------- # # The first run # --------------------------------------------------------------------------- # def test_a_dashboard_with_nothing_recorded_invites_rather_than_showing_zeros( client, session_factory): """Five $0.00 tiles and an empty table is a page that looks broken, not new. The claim is about which of the two states renders, so it asserts on both halves: the invitation is there, and the tiles that would otherwise be all zeroes are not. """ make_login(client, session_factory) page = client.get("/", headers={"accept": "text/html"}).text assert "Nothing recorded yet" in page assert 'class="tiles"' not in page # Both ways in are offered — importing is the faster one for anyone # arriving with history, so it must not be reachable only from the nav. assert "/imports-exports" in page def test_the_first_run_state_goes_away_once_something_is_recorded( client, session_factory): """The other direction, so the test above cannot pass by never rendering the real dashboard at all.""" make_login(client, session_factory) _one_holding(session_factory) page = client.get("/", headers={"accept": "text/html"}).text assert "Nothing recorded yet" not in page assert 'class="tiles"' in page def test_a_new_portfolio_on_a_shared_catalogue_is_still_a_first_run(client, session_factory): """The instruments are a catalogue every portfolio shares (decisions.md #125), and the dashboard counted all of them: once one portfolio had a holding, every portfolio made after it opened on zeros, not this.""" make_login(client, session_factory) with session_factory() as s: stranger = fac.make_user(s, "stranger@example.test") theirs = fac.make_portfolio(s, "Their portfolio", owner=stranger) s.flush() tenancy.bind(s, theirs.id, stranger.id) fac.add_trade(s, fac.make_instrument(s, "ACME"), "2026-01-05", "buy", 100, "4.00") s.commit() page = client.get("/", headers={"accept": "text/html"}).text assert "Nothing recorded yet" in page assert 'class="tiles"' not in page def test_selling_everything_is_not_treated_as_a_first_run(client, session_factory): """A closed position is history, not a blank slate: the tiles still have a story (realised gain, dividends) and the FY pages need the year tabs. The empty state here is about the TABLE, not the page.""" 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.add_trade(s, acme, "2026-01-05", "buy", 100, "4.00") fac.add_trade(s, acme, "2026-02-05", "sell", 100, "5.00") s.commit() page = client.get("/", headers={"accept": "text/html"}).text assert "Nothing recorded yet" not in page assert 'class="tiles"' in page assert "No open positions." in page # The three optional columns added in T35. All read a value off # `queries.Holding`, so what matters is that the number is the one the header # claims — two have a plausible-but-wrong formula sitting beside the right one. # --------------------------------------------------------------------------- # @freeze_time(ref.TODAY) def test_the_reporting_avg_price_is_the_avg_price_converted_not_cost_over_units(pf): """The one that is easy to get wrong, and looks right when it is. `cost_aud / units` is tempting: both figures are already on the holding. But `cost` is buys only and includes brokerage, `avg_price` is buys AND DRP allocations and excludes it, and `units` is net of sales — so that division is a third quantity wearing the Avg price column's name. Two columns headed "Avg price" must differ ONLY by the exchange rate. """ usd = fac.make_instrument(pf, "NOVA", exchange="NASDAQ", asset_class="share", currency="USD", name="Nova Inc") # 100 @ $10 with $20 brokerage at 0.50, then a DRP of 10 @ $12 at 0.40. fac.add_trade(pf, usd, "2025-01-06", "buy", 100, "10.00", brokerage="20.00", fx_rate="0.50") fac.add_trade(pf, usd, "2025-06-02", "drp", 10, "12.00", fx_rate="0.40") fac.add_fx(pf, "USDAUD", "2026-08-01", "0.60") fac.add_price(pf, usd, "2026-08-01", "15.00") holding = next(h for h in queries.all_holdings(pf) if h.instrument.ticker == "NOVA") # (100 x 10 x 0.50) + (10 x 12 x 0.40) = 548.00 over 110 accumulated units. assert holding.avg_price_aud == Decimal("548.00") / Decimal(110) # The shortcut would have been (100 x 10 + 20) x 0.50 / 110 = 4.6363…, # which is neither the avg price nor in the same currency as one. assert holding.avg_price_aud != holding.cost_aud / holding.units # Brokerage is excluded here exactly as it is from the native avg price. assert holding.avg_price == Decimal("1120") / Decimal(110) # 1000 + 120 @freeze_time(ref.TODAY) def test_the_reporting_price_is_the_latest_close_at_the_latest_rate(pf): usd = fac.make_instrument(pf, "NOVA", exchange="NASDAQ", asset_class="share", currency="USD", name="Nova Inc") fac.add_trade(pf, usd, "2025-01-06", "buy", 100, "10.00", fx_rate="0.50") fac.add_fx(pf, "USDAUD", "2026-08-01", "0.60") fac.add_price(pf, usd, "2026-08-01", "15.00") holding = next(h for h in queries.all_holdings(pf) if h.instrument.ticker == "NOVA") assert holding.price_aud == Decimal("9.00") # 15.00 x 0.60 @freeze_time(ref.TODAY) def test_the_drp_residual_is_the_latest_balance_and_not_a_running_total(pf): """Each dividend stores the balance held AFTER it, so summing them counts the same few cents once per distribution.""" acme = fac.make_instrument(pf, "ACME", name="Acme Pty") fac.add_trade(pf, acme, "2025-01-06", "buy", 100, "10.00") for date, residual in (("2025-03-10", "4.10"), ("2025-09-10", "7.25"), ("2026-03-10", "2.80")): fac.add_dividend(pf, acme, date, "50.00").residual_carried = Decimal(residual) pf.flush() holding = next(h for h in queries.all_holdings(pf) if h.instrument.ticker == "ACME") assert holding.drp_residual == Decimal("2.8000") @freeze_time(ref.TODAY) def test_a_residual_that_was_never_recorded_stays_distinct_from_a_recorded_zero(pf): """NULL means nobody knows; 0 means the registry kept nothing. 0018 made the column nullable for exactly this, and the column must not flatten it.""" blank = fac.make_instrument(pf, "BLNK", name="Blank Pty") fac.add_trade(pf, blank, "2025-01-06", "buy", 100, "10.00") fac.add_dividend(pf, blank, "2025-03-10", "50.00") kept = fac.make_instrument(pf, "ZERO", name="Zero Pty") fac.add_trade(pf, kept, "2025-01-06", "buy", 100, "10.00") fac.add_dividend(pf, kept, "2025-03-10", "50.00").residual_carried = Decimal(0) pf.flush() holdings = {h.instrument.ticker: h for h in queries.all_holdings(pf)} assert holdings["BLNK"].drp_residual is None assert holdings["ZERO"].drp_residual == Decimal(0) def test_the_new_columns_are_all_off_by_default(): """Nobody's dashboard moves because a column was added.""" for key in ("avg_price_reporting", "price_reporting", "residual"): assert key in columns.BY_KEY assert key not in columns.DEFAULTS def test_the_reporting_columns_name_their_currency_in_the_header(): """`avg_price` and `avg_price_reporting` share the label "Avg price" and are told apart ONLY by the currency, which is why the chooser always marks.""" native = columns.BY_KEY["avg_price"].heading("USD") reporting = columns.BY_KEY["avg_price_reporting"].heading("USD") assert native == "Avg price (USD)" assert reporting == "Avg price (AUD)" # --------------------------------------------------------------------------- # # What a mutation run found unchecked # --------------------------------------------------------------------------- # def test_the_drp_column_says_yes_or_nothing(): value = columns.BY_KEY["drp"].value assert value(_FakeHolding(ticker="A", drp=True)) == "yes" assert value(_FakeHolding(ticker="B", drp=False)) == "" def test_a_holding_with_no_name_shows_a_blank_not_none(): assert columns.BY_KEY["name"].value(_FakeHolding(ticker="A", name=None)) == "" @pytest.mark.parametrize(("chosen", "offered", "withheld"), [ (set(), "cost", "cost_reporting"), # neither named: native ({"cost"}, "cost", "cost_reporting"), ({"cost_reporting"}, "cost_reporting", "cost"), # the one the layout names ({"cost", "cost_reporting"}, "cost", "cost_reporting"), ]) def test_one_currency_offers_one_of_each_twin_the_layout_names_first(chosen, offered, withheld): keys = {c.key for _g, items in columns.chooser_groups(True, chosen) for c in items} assert offered in keys and withheld not in keys def test_two_currencies_offer_both_twins(): keys = {c.key for _g, items in columns.chooser_groups(False, set()) for c in items} assert {"cost", "cost_reporting"} <= keys