main
py 173 lines 6.46 KB
Raw
1 from __future__ import annotations
2
3 from datetime import datetime, timedelta, timezone
4
5 import httpx
6 import pytest
7
8 from app.application_client import HttpApplicationResearchReader
9 from app.contracts import McpErrorCode, McpGatewayError
10 from conftest import INSTRUMENT_ID
11
12
13 @pytest.mark.asyncio
14 async def test_http_reader_uses_only_persisted_read_routes_and_propagates_correlation(
15 settings, auth, monkeypatch
16 ) -> None:
17 captured: list[httpx.Request] = []
18 monkeypatch.setattr(
19 "app.application_client.inject",
20 lambda headers: headers.update({"traceparent": "00-00000000000000000000000000001234-0000000000005678-01"}),
21 )
22
23 def transport(request: httpx.Request) -> httpx.Response:
24 captured.append(request)
25 return httpx.Response(
26 200,
27 json={"globalInstrumentId": str(INSTRUMENT_ID), "overallStatus": "READY"},
28 )
29
30 client = httpx.AsyncClient(
31 base_url="http://research-engine",
32 transport=httpx.MockTransport(transport),
33 )
34 reader = HttpApplicationResearchReader(settings, client=client)
35 result = await reader.invoke(
36 "get_research_readiness",
37 {"globalInstrumentId": str(INSTRUMENT_ID)},
38 auth,
39 "request-reader",
40 )
41 assert result["overallStatus"] == "READY"
42 assert captured[0].url.path == f"/api/v1/research/readiness/{INSTRUMENT_ID}"
43 assert "ensure" not in captured[0].url.path and "refresh" not in captured[0].url.path
44 assert captured[0].headers["x-request-id"] == "request-reader"
45 assert captured[0].headers["x-correlation-id"] == "request-reader"
46 assert captured[0].headers["traceparent"].startswith("00-00000000000000000000000000001234")
47 await client.aclose()
48
49
50 @pytest.mark.asyncio
51 async def test_news_projection_uses_publication_window_and_provenance(settings, auth) -> None:
52 now = datetime.now(timezone.utc)
53
54 def transport(_request: httpx.Request) -> httpx.Response:
55 return httpx.Response(
56 200,
57 json={
58 "recentEvents": [
59 {
60 "eventId": "current",
61 "title": "Current filing",
62 "publishedAt": (now - timedelta(days=2)).isoformat(),
63 "sourceUrl": "https://example.test/current",
64 "sourceType": "EXCHANGE_FILING",
65 },
66 {
67 "eventId": "historical-governance",
68 "title": "Historical event",
69 "eventDate": (now - timedelta(days=45)).isoformat(),
70 "sourceType": "GOVERNANCE_HISTORY",
71 },
72 ]
73 },
74 )
75
76 client = httpx.AsyncClient(base_url="http://research-engine", transport=httpx.MockTransport(transport))
77 reader = HttpApplicationResearchReader(settings, client=client)
78 result = await reader.invoke(
79 "get_recent_news",
80 {"globalInstrumentId": str(INSTRUMENT_ID), "days": 30},
81 auth,
82 "news-request",
83 )
84 assert [item["eventId"] for item in result["news"]] == ["current"]
85 assert result["news"][0]["publicationDate"]
86 assert result["news"][0]["dateBasis"] == "PUBLISHED_AT"
87 assert result["news"][0]["source"]["type"] == "EXCHANGE_FILING"
88 await client.aclose()
89
90
91 @pytest.mark.asyncio
92 async def test_analysis_and_sector_routes_never_use_refresh_or_ensure(settings, auth) -> None:
93 captured: list[httpx.Request] = []
94
95 def transport(request: httpx.Request) -> httpx.Response:
96 captured.append(request)
97 if request.url.path.startswith("/api/v1/research/analysis/"):
98 return httpx.Response(
99 200,
100 json={
101 "globalInstrumentId": str(INSTRUMENT_ID),
102 "ruleEngineVersion": "STOCK_RULE_ENGINE_V1",
103 },
104 )
105 return httpx.Response(200, json={"region": "INDIA", "sector": "FINANCIAL_SERVICES"})
106
107 client = httpx.AsyncClient(base_url="http://research-engine", transport=httpx.MockTransport(transport))
108 reader = HttpApplicationResearchReader(settings, client=client)
109 analysis = await reader.invoke(
110 "get_company_analysis",
111 {"globalInstrumentId": str(INSTRUMENT_ID), "allowPartial": False},
112 auth,
113 "analysis-request",
114 )
115 sector = await reader.invoke(
116 "get_sector_performance",
117 {"region": "INDIA", "sector": "FINANCIAL_SERVICES", "period": "MONTH", "limit": 5},
118 auth,
119 "sector-request",
120 )
121 assert analysis["ruleEngineVersion"] == "STOCK_RULE_ENGINE_V1"
122 assert sector["sector"] == "FINANCIAL_SERVICES"
123 assert [request.url.path for request in captured] == [
124 f"/api/v1/research/analysis/{INSTRUMENT_ID}",
125 "/api/v1/research/sector-performance",
126 ]
127 assert all("ensure" not in request.url.path and "refresh" not in request.url.path for request in captured)
128 await client.aclose()
129
130
131 @pytest.mark.asyncio
132 @pytest.mark.parametrize(
133 ("tool", "expected"),
134 [
135 ("get_research_readiness", McpErrorCode.READINESS_NOT_AVAILABLE),
136 ("get_company_analysis", McpErrorCode.ANALYSIS_NOT_AVAILABLE),
137 ],
138 )
139 async def test_missing_read_models_map_to_deterministic_errors(settings, auth, tool, expected) -> None:
140 client = httpx.AsyncClient(
141 base_url="http://research-engine",
142 transport=httpx.MockTransport(lambda _request: httpx.Response(404, json={"detail": "not available"})),
143 )
144 reader = HttpApplicationResearchReader(settings, client=client)
145 with pytest.raises(McpGatewayError) as error:
146 await reader.invoke(
147 tool,
148 {"globalInstrumentId": str(INSTRUMENT_ID)},
149 auth,
150 "missing-read-model",
151 )
152 assert error.value.code == expected
153 await client.aclose()
154
155
156 @pytest.mark.asyncio
157 async def test_unresolved_canonical_identity_is_distinguished(settings, auth) -> None:
158 client = httpx.AsyncClient(
159 base_url="http://research-engine",
160 transport=httpx.MockTransport(
161 lambda _request: httpx.Response(404, json={"detail": "COMPANY_NOT_RESOLVED"})
162 ),
163 )
164 reader = HttpApplicationResearchReader(settings, client=client)
165 with pytest.raises(McpGatewayError) as error:
166 await reader.invoke(
167 "get_research_readiness",
168 {"globalInstrumentId": str(INSTRUMENT_ID)},
169 auth,
170 "unresolved-company",
171 )
172 assert error.value.code == McpErrorCode.COMPANY_NOT_RESOLVED
173 await client.aclose()