main
py 176 lines 7.78 KB
Raw
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