| 1 | from uuid import uuid4 |
| 2 | |
| 3 | import httpx |
| 4 | import pytest |
| 5 | |
| 6 | from app.international_fundamentals import InternationalFundamentalsResult, SecEdgarFundamentalProvider, EodhdFundamentalProvider |
| 7 | from app.models import CompanyResearchProfile |
| 8 | from app.portfolio_orchestration import PortfolioResearchOrchestrator |
| 9 | from app.repository import ResearchRepository |
| 10 | from app.settings import Settings |
| 11 | |
| 12 | |
| 13 | def profile(**changes): |
| 14 | value = dict(instrument_id=uuid4(), company_id=uuid4(), company_name="Example", ticker="MSFT", exchange="XNAS", mic="XNAS", country="US", currency="USD") |
| 15 | value.update(changes) |
| 16 | return CompanyResearchProfile(**value) |
| 17 | |
| 18 | |
| 19 | class Provider: |
| 20 | def __init__(self, result=None, error=None): self.result, self.error = result, error |
| 21 | async def collect(self, _profile): |
| 22 | if self.error: raise self.error |
| 23 | return self.result |
| 24 | |
| 25 | |
| 26 | @pytest.mark.asyncio |
| 27 | @pytest.mark.parametrize(("provider_id", "ticker"), [("SEC_CIK", "0000789019"), ("EODHD", "AIXA.F")]) |
| 28 | async def test_verified_mapping_is_written_once_through_portfolio_boundary(monkeypatch, provider_id, ticker): |
| 29 | captured = [] |
| 30 | async def handler(request): |
| 31 | captured.append(request) |
| 32 | return httpx.Response(200, json={}) |
| 33 | p = profile() |
| 34 | client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) |
| 35 | provider = Provider(InternationalFundamentalsResult([], {provider_id: ticker})) |
| 36 | monkeypatch.setattr("app.portfolio_orchestration.international_provider_for", lambda *_args, **_kwargs: provider) |
| 37 | orchestrator = PortfolioResearchOrchestrator(ResearchRepository(Settings()), Settings(), client=client) |
| 38 | await orchestrator.refresh_international_fundamentals( |
| 39 | p, |
| 40 | correlation_id="mapping-correlation", |
| 41 | identity_headers={"X-AIP-User-Id": "internal-research", "X-AIP-User-Roles": "ADMIN"}, |
| 42 | ) |
| 43 | assert len(captured) == 1 |
| 44 | assert captured[0].method == "PUT" |
| 45 | assert captured[0].url.path == f"/api/v1/instruments/{p.instrument_id}/provider-mappings/verified" |
| 46 | assert captured[0].headers["x-aip-user-id"] == "internal-research" |
| 47 | assert captured[0].headers["x-aip-user-roles"] == "ADMIN" |
| 48 | assert captured[0].headers["x-correlation-id"] == "mapping-correlation" |
| 49 | payload = __import__("json").loads(captured[0].content) |
| 50 | assert payload["provider"] == provider_id and payload["providerInstrumentId"] == ticker |
| 51 | assert payload["resolutionSource"] == "RESEARCH_ENGINE_VERIFIED_FUNDAMENTALS" and payload["confidence"] == 0.90 |
| 52 | await client.aclose() |
| 53 | |
| 54 | |
| 55 | @pytest.mark.asyncio |
| 56 | async def test_empty_or_failed_provider_never_writes_mapping(monkeypatch): |
| 57 | calls = [] |
| 58 | async def handler(request): calls.append(request); return httpx.Response(200) |
| 59 | client = httpx.AsyncClient(transport=httpx.MockTransport(handler)); p = profile() |
| 60 | orchestrator = PortfolioResearchOrchestrator(ResearchRepository(Settings()), Settings(), client=client) |
| 61 | monkeypatch.setattr("app.portfolio_orchestration.international_provider_for", lambda *_args, **_kwargs: Provider(InternationalFundamentalsResult([], {}))) |
| 62 | await orchestrator.refresh_international_fundamentals(p) |
| 63 | monkeypatch.setattr("app.portfolio_orchestration.international_provider_for", lambda *_args, **_kwargs: Provider(error=RuntimeError("verification failed"))) |
| 64 | with pytest.raises(RuntimeError): await orchestrator.refresh_international_fundamentals(p) |
| 65 | assert calls == [] |
| 66 | await client.aclose() |
| 67 | |
| 68 | |
| 69 | @pytest.mark.asyncio |
| 70 | async def test_trusted_sec_cik_bypasses_ticker_discovery(monkeypatch): |
| 71 | p = profile(provider_instrument_ids={"SEC_CIK": "0000789019"}); calls = [] |
| 72 | async def forbidden(*_args): raise AssertionError("CIK discovery must not run") |
| 73 | def handler(request): |
| 74 | calls.append(request.url.path) |
| 75 | return httpx.Response(200, json={"facts": {"us-gaap": {}}}) |
| 76 | provider = SecEdgarFundamentalProvider(Settings(), client=httpx.AsyncClient(transport=httpx.MockTransport(handler))) |
| 77 | monkeypatch.setattr(provider, "_resolve_cik", forbidden) |
| 78 | result = await provider.collect(p) |
| 79 | assert result.verified_provider_ids == {"SEC_CIK": "0000789019"} |
| 80 | assert calls == ["/api/xbrl/companyfacts/CIK0000789019.json"] |
| 81 | |
| 82 | |
| 83 | @pytest.mark.asyncio |
| 84 | async def test_trusted_eodhd_symbol_bypasses_ticker_fallback(): |
| 85 | p = profile(provider_instrument_ids={"EODHD": "AIXA.F"}); paths = [] |
| 86 | def handler(request): |
| 87 | paths.append(request.url.path) |
| 88 | return httpx.Response(200, json={"General": {"Exchange": "XNAS", "CurrencyCode": "USD"}, "Financials": {}}) |
| 89 | provider = EodhdFundamentalProvider(Settings(eodhd_api_key="x", eodhd_base_url="https://eod.test"), client=httpx.AsyncClient(transport=httpx.MockTransport(handler))) |
| 90 | result = await provider.collect(p) |
| 91 | assert result.verified_provider_ids == {"EODHD": "AIXA.F"} |
| 92 | assert paths == ["/fundamentals/AIXA.F"] |
| 93 | |
| 94 | |
| 95 | @pytest.mark.asyncio |
| 96 | async def test_india_never_enters_mapping_boundary(monkeypatch): |
| 97 | p = profile(country="IN", exchange="NSE", mic="XNSE") |
| 98 | monkeypatch.setattr("app.portfolio_orchestration.international_provider_for", lambda *_args, **_kwargs: None) |
| 99 | orchestrator = PortfolioResearchOrchestrator(ResearchRepository(Settings()), Settings()) |
| 100 | assert await orchestrator.refresh_international_fundamentals(p) is None |