| 1 | from decimal import Decimal |
| 2 | from types import SimpleNamespace |
| 3 | from uuid import uuid4 |
| 4 | |
| 5 | import httpx |
| 6 | import pytest |
| 7 | from fastapi import HTTPException |
| 8 | |
| 9 | import app.main as main |
| 10 | from app.models import PortfolioResearchCompany |
| 11 | from app.portfolio_orchestration import ( |
| 12 | PortfolioResearchOrchestrator, |
| 13 | WatchlistNotFoundError, |
| 14 | WatchlistRegionMismatchError, |
| 15 | ) |
| 16 | from app.settings import Settings |
| 17 | from app.watchlists import AddWatchlistInstrumentRequest, EnsureDefaultWatchlistRequest, watchlist_research_projection |
| 18 | |
| 19 | |
| 20 | @pytest.mark.asyncio |
| 21 | async def test_portfolio_watchlist_client_forwards_user_identity_and_uses_provider_free_contracts(caplog): |
| 22 | watchlist_id = uuid4() |
| 23 | instrument_id = uuid4() |
| 24 | calls = [] |
| 25 | |
| 26 | def handler(request: httpx.Request) -> httpx.Response: |
| 27 | calls.append(request) |
| 28 | if request.url.path.endswith("/default/ensure"): |
| 29 | return httpx.Response(200, json={"watchlistId": str(watchlist_id), "name": "WATCHLIST-IND", "region": "INDIA"}) |
| 30 | if request.method == "POST": |
| 31 | return httpx.Response(200, json={"globalInstrumentId": str(instrument_id)}) |
| 32 | if request.method == "DELETE": |
| 33 | return httpx.Response(204) |
| 34 | return httpx.Response(200, json=[]) |
| 35 | |
| 36 | async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: |
| 37 | orchestrator = PortfolioResearchOrchestrator( |
| 38 | SimpleNamespace(), Settings(portfolio_service_base_url="http://portfolio-service"), client=client |
| 39 | ) |
| 40 | identity = {"X-AIP-User-Id": "browser-user", "X-AIP-User-Subject": "subject"} |
| 41 | ensured = await orchestrator.ensure_default_watchlist("INDIA", identity_headers=identity) |
| 42 | await orchestrator.add_watchlist_instrument( |
| 43 | watchlist_id, |
| 44 | {"globalInstrumentId": str(instrument_id), "sourcePeriod": "WEEK", "sourcePerformancePct": 13.43}, |
| 45 | identity_headers=identity, |
| 46 | ) |
| 47 | await orchestrator.remove_watchlist_instrument(watchlist_id, instrument_id, identity_headers=identity) |
| 48 | |
| 49 | assert ensured["name"] == "WATCHLIST-IND" |
| 50 | assert [call.url.path for call in calls] == [ |
| 51 | "/api/v1/watchlists/default/ensure", |
| 52 | f"/api/v1/watchlists/{watchlist_id}/instruments", |
| 53 | f"/api/v1/watchlists/{watchlist_id}/instruments/{instrument_id}", |
| 54 | ] |
| 55 | assert all(call.headers["X-AIP-User-Id"] == "browser-user" for call in calls) |
| 56 | assert all("portfolio" not in call.url.path for call in calls) |
| 57 | assert "browser-user" not in caplog.text |
| 58 | |
| 59 | |
| 60 | @pytest.mark.asyncio |
| 61 | async def test_watchlist_client_preserves_region_mismatch_and_ownership_errors(): |
| 62 | watchlist_id = uuid4() |
| 63 | |
| 64 | def handler(request: httpx.Request) -> httpx.Response: |
| 65 | if request.method == "POST": |
| 66 | return httpx.Response(409, json={"code": "WATCHLIST_REGION_MISMATCH"}) |
| 67 | return httpx.Response(404, json={"code": "WATCHLIST_NOT_FOUND"}) |
| 68 | |
| 69 | async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: |
| 70 | orchestrator = PortfolioResearchOrchestrator( |
| 71 | SimpleNamespace(), Settings(portfolio_service_base_url="http://portfolio-service"), client=client |
| 72 | ) |
| 73 | with pytest.raises(WatchlistRegionMismatchError): |
| 74 | await orchestrator.add_watchlist_instrument(watchlist_id, {"globalInstrumentId": str(uuid4())}) |
| 75 | with pytest.raises(WatchlistNotFoundError): |
| 76 | await orchestrator.watchlist(watchlist_id) |
| 77 | |
| 78 | |
| 79 | @pytest.mark.asyncio |
| 80 | async def test_watchlist_research_projection_is_non_held_and_reuses_durable_company_read_model(): |
| 81 | watchlist_id = uuid4() |
| 82 | instrument_id = uuid4() |
| 83 | calls = [] |
| 84 | metadata = { |
| 85 | "globalInstrumentId": str(instrument_id), |
| 86 | "canonicalName": "Prime Focus Ltd.", |
| 87 | "primarySymbol": "PFOCUS", |
| 88 | "primaryExchange": "NSE", |
| 89 | "country": "IN", |
| 90 | "currency": "INR", |
| 91 | "assetType": "EQUITY", |
| 92 | "providerMappings": [], |
| 93 | } |
| 94 | |
| 95 | async def read_state(value, **kwargs): |
| 96 | calls.append((value, kwargs)) |
| 97 | return PortfolioResearchCompany( |
| 98 | instrument_id=instrument_id, |
| 99 | company_name="Prime Focus Ltd.", |
| 100 | ticker="PFOCUS", |
| 101 | exchange="NSE", |
| 102 | status="RESOLVED_RESEARCH_AVAILABLE", |
| 103 | ) |
| 104 | |
| 105 | projected = await watchlist_research_projection( |
| 106 | SimpleNamespace(read_global_company_state=read_state), |
| 107 | { |
| 108 | "watchlist": {"watchlistId": str(watchlist_id), "name": "WATCHLIST-IND", "region": "INDIA"}, |
| 109 | "instruments": [{ |
| 110 | "globalInstrumentId": str(instrument_id), "instrument": metadata, |
| 111 | "sourcePeriod": "WEEK", "sourcePerformancePct": 13.43, |
| 112 | }], |
| 113 | }, |
| 114 | identity_headers={"X-AIP-User-Id": "owner"}, |
| 115 | ) |
| 116 | |
| 117 | row = projected["instruments"][0] |
| 118 | assert row["globalInstrumentId"] == str(instrument_id) |
| 119 | assert row["held"] is False |
| 120 | assert not {"quantity", "averageBuyPrice", "costBasis", "investedAmount", "portfolioPnl", "allocation", "broker"} & row.keys() |
| 121 | assert row["sourcePeriod"] == "WEEK" and row["sourcePerformancePct"] == 13.43 |
| 122 | assert row["company"]["instrumentId"] == str(instrument_id) |
| 123 | assert calls[0][1]["metadata"] is metadata |
| 124 | assert "refresh" not in repr(calls).lower() |
| 125 | |
| 126 | |
| 127 | @pytest.mark.asyncio |
| 128 | async def test_watchlist_routes_are_authenticated_and_membership_payload_is_provider_neutral(monkeypatch): |
| 129 | watchlist_id = uuid4() |
| 130 | instrument_id = uuid4() |
| 131 | observed = {} |
| 132 | |
| 133 | class Stub: |
| 134 | async def ensure_default_watchlist(self, region, **kwargs): |
| 135 | observed["ensure"] = (region, kwargs) |
| 136 | return {"watchlistId": str(watchlist_id), "name": "WATCHLIST-IND", "region": region} |
| 137 | |
| 138 | async def add_watchlist_instrument(self, value, payload, **kwargs): |
| 139 | observed["add"] = (value, payload, kwargs) |
| 140 | return {"globalInstrumentId": payload["globalInstrumentId"]} |
| 141 | |
| 142 | monkeypatch.setattr(main, "portfolio_orchestrator", Stub()) |
| 143 | identity = dict(x_aip_user_id="owner", x_aip_user_issuer="gateway", x_aip_user_subject="subject") |
| 144 | ensured = await main.ensure_default_research_watchlist( |
| 145 | EnsureDefaultWatchlistRequest(region="INDIA"), **identity |
| 146 | ) |
| 147 | request = AddWatchlistInstrumentRequest.model_validate({ |
| 148 | "globalInstrumentId": str(instrument_id), "sourcePeriod": "WEEK", "sourcePerformancePct": "13.43" |
| 149 | }) |
| 150 | await main.add_research_watchlist_instrument(watchlist_id, request, **identity) |
| 151 | |
| 152 | assert ensured["name"] == "WATCHLIST-IND" |
| 153 | assert observed["ensure"][1]["identity_headers"]["X-AIP-User-Id"] == "owner" |
| 154 | assert observed["add"][1] == { |
| 155 | "globalInstrumentId": str(instrument_id), "sourcePeriod": "WEEK", "sourcePerformancePct": 13.43 |
| 156 | } |
| 157 | assert not any(value in repr(observed["add"][1]) for value in ("NSE", "SEC", "EODHD", "portfolioId", "quantity")) |
| 158 | |
| 159 | with pytest.raises(HTTPException) as denied: |
| 160 | await main.ensure_default_research_watchlist( |
| 161 | EnsureDefaultWatchlistRequest(region="USA"), |
| 162 | x_aip_user_id=None, x_aip_user_issuer=None, x_aip_user_subject=None, |
| 163 | ) |
| 164 | assert denied.value.status_code == 401 |
| 165 | |
| 166 | |
| 167 | def test_watchlist_routes_are_explicit_and_distinct_from_targeted_research_routes(): |
| 168 | routes = {(route.path, method) for route in main.app.routes for method in getattr(route, "methods", set())} |
| 169 | assert ("/api/v1/research/watchlists", "GET") in routes |
| 170 | assert ("/api/v1/research/watchlists/default/ensure", "POST") in routes |
| 171 | assert ("/api/v1/research/watchlists/{watchlist_id}/instruments", "POST") in routes |
| 172 | assert ("/api/v1/research/watchlists/{watchlist_id}/instruments/{instrument_id}", "DELETE") in routes |
| 173 | assert ("/api/v1/research/watchlists/{watchlist_id}/research", "GET") in routes |
| 174 | assert ("/api/v1/research/readiness/{global_instrument_id}", "GET") in routes |
| 175 | assert ("/api/v1/research/readiness/{global_instrument_id}/ensure", "POST") in routes |
| 176 | assert ("/api/v1/research/prefetch", "POST") not in routes |