"""One portfolio holding one ticker on two exchanges. BHP on the ASX and BHP on the NYSE are two holdings with one code. Every path that took a bare ticker took the older of the two: the holding page and every link and redirect to it, a dividend statement, the API, and the plan's rotation. Each now names the exchange when the ticker alone is not enough, and only then, so a ticker held once keeps the address and the form it had. """ from __future__ import annotations import html import re from freezegun import freeze_time from sqlalchemy import select from test_api import bearer, issue_key from test_routes_plan import TODAY, save_plan import factories as fac from app import statements, tenancy from app.models import Dividend, Instrument, InvestmentPlanEntry, Portfolio, Trade from test_routes import make_login, session_csrf HTML = {"accept": "text/html"} def _both(session_factory) -> dict: """Mine holds BHP on the ASX, then BHP on the NYSE, and ACME once.""" with session_factory() as s: mine = s.scalar(select(Portfolio)) asx = fac.make_instrument(s, "BHP", name="BHP on the ASX") nyse = fac.make_instrument(s, "BHP", exchange="NYSE", currency="USD", name="BHP on the NYSE") acme = fac.make_instrument(s, "ACME", name="Acme") fac.add_trade(s, asx, "2026-01-06", "buy", 7, "40.00", portfolio_id=mine.id) fac.add_trade(s, nyse, "2026-01-07", "buy", 2, "60.00", fx_rate="1.50", portfolio_id=mine.id) fac.add_trade(s, acme, "2026-01-05", "buy", 10, "5.00", portfolio_id=mine.id) s.commit() return {"asx": asx.id, "nyse": nyse.id, "acme": acme.id, "mine": mine.id, "owner": mine.members[0].user_id} def _unscoped(session_factory, model): with session_factory() as s: tenancy.allow_unscoped(s) return list(s.scalars(select(model))) def _hrefs(page: str) -> set[str]: return {html.unescape(h) for h in re.findall(r'href="(/holding/[^"]*)"', page)} def test_each_listing_has_its_own_page(client, session_factory): make_login(client, session_factory) _both(session_factory) nyse = client.get("/holding/BHP?exchange=NYSE", headers=HTML).text bare = client.get("/holding/BHP", headers=HTML).text assert "BHP on the NYSE" in nyse and "BHP on the ASX" not in nyse assert "BHP on the ASX" in bare # the older, as before, assert "/holding/BHP?exchange=NYSE" in _hrefs(bare) # with the other one click away assert client.get("/holding/BHP?exchange=LSE", headers=HTML).status_code == 404 def test_a_link_names_the_exchange_only_when_the_ticker_needs_it(client, session_factory): make_login(client, session_factory) _both(session_factory) links = _hrefs(client.get("/holdings", headers=HTML).text) assert {"/holding/BHP?exchange=ASX&return=/holdings", "/holding/BHP?exchange=NYSE&return=/holdings", "/holding/ACME?return=/holdings"} <= links def test_a_delete_on_one_listing_returns_to_that_listing(client, session_factory): make_login(client, session_factory) ids = _both(session_factory) with session_factory() as s: nyse = s.get(Instrument, ids["nyse"]) extra = fac.add_trade(s, nyse, "2026-01-08", "buy", 1, "61.00", fx_rate="1.50", portfolio_id=ids["mine"]) s.commit() extra_id = extra.id resp = client.post(f"/trade/{extra_id}/delete", data={"_csrf": session_csrf(session_factory)}, headers=HTML, follow_redirects=False) assert resp.headers["location"] == "/holding/BHP?exchange=NYSE" def test_a_statement_is_recorded_against_the_listing_chosen(client, session_factory, monkeypatch): make_login(client, session_factory) ids = _both(session_factory) monkeypatch.setattr(statements, "_pdf_text", lambda data: ( "BHP Group dividend advice\nBHP\nPayment Date: 15 March 2026\nNet Amount: $10.00\n")) page = client.post("/imports-exports/statement", files={"file": ("advice.pdf", b"%PDF-1.4", "application/pdf")}, data={"_csrf": session_csrf(session_factory)}, headers=HTML).text select = re.search(r'', form, re.S).group(0) picked = re.findall(r'