| 1 | """Regression tests for global presentation vs. readiness provider-mapping trust alignment. |
| 2 | |
| 3 | Canonical rule: |
| 4 | globalInstrumentId -> verified provider mapping -> provider symbol |
| 5 | |
| 6 | `read_global_company_state` (presentation/summary) projects through |
| 7 | `_read_company_state_impl` -> `_resolve_profile` -> `_hydrate_verified_exchange_mappings`, |
| 8 | which RETAINS previously-verified provider mappings (it only adds trusted mappings |
| 9 | from the instrument and never removes stale ones). The readiness path |
| 10 | (`register_global_profile_metadata` -> `_refresh_profile_from_global_instrument`) |
| 11 | REPLACE`S` the mapping set with verified-only entries via `_trusted_provider_mapping`. |
| 12 | |
| 13 | These projections must agree, so `_read_company_state_impl` now re-aligns the global |
| 14 | (provider-less) instrument through `_refresh_profile_from_global_instrument`. Portfolio |
| 15 | positions always carry a broker `provider`, so the `if not instrument.get("provider")` |
| 16 | guard leaves the portfolio retain path -- and broker-provenance identity -- untouched. |
| 17 | """ |
| 18 | import asyncio |
| 19 | from uuid import UUID, uuid4 |
| 20 | |
| 21 | from app.portfolio_orchestration import PortfolioResearchOrchestrator |
| 22 | from app.repository import ResearchRepository |
| 23 | from app.settings import Settings |
| 24 | |
| 25 | |
| 26 | def _global_instrument_payload(global_id: UUID, yahoo_status: str = "VERIFIED") -> dict: |
| 27 | """A portfolio-service global-instrument master payload (no broker `provider`).""" |
| 28 | return { |
| 29 | "globalInstrumentId": str(global_id), |
| 30 | "canonicalName": "Venus Pipes And Fittings Limited", |
| 31 | "isin": "INE000V01010", |
| 32 | "assetType": "EQUITY", |
| 33 | "country": "IN", |
| 34 | "currency": "INR", |
| 35 | "primaryExchange": "NSE", |
| 36 | "primarySymbol": "VENUSPIPES", |
| 37 | "providerMappings": [ |
| 38 | {"provider": "NSE", "providerSymbol": "VENUSPIPES", "status": "VERIFIED", "exchange": "NSE"}, |
| 39 | {"provider": "YAHOO_FINANCE", "providerSymbol": "VENUSPIPES.NS", "status": yahoo_status, "exchange": "NSE"}, |
| 40 | ], |
| 41 | } |
| 42 | |
| 43 | |
| 44 | def _no_yahoo_payload(global_id: UUID) -> dict: |
| 45 | """Global instrument whose master mappings contain no Yahoo entry at all.""" |
| 46 | return { |
| 47 | "globalInstrumentId": str(global_id), |
| 48 | "canonicalName": "No Yahoo Company Limited", |
| 49 | "isin": "INE000N01010", |
| 50 | "assetType": "EQUITY", |
| 51 | "country": "IN", |
| 52 | "currency": "INR", |
| 53 | "primaryExchange": "NSE", |
| 54 | "primarySymbol": "NOYAHOO", |
| 55 | "providerMappings": [ |
| 56 | {"provider": "NSE", "providerSymbol": "NOYAHOO", "status": "VERIFIED", "exchange": "NSE"}, |
| 57 | ], |
| 58 | } |
| 59 | |
| 60 | |
| 61 | class _OfflineClient: |
| 62 | """Asserts the offline global presentation path performs no portfolio-service HTTP.""" |
| 63 | |
| 64 | async def get(self, *args, **kwargs): |
| 65 | raise AssertionError("read_global_company_state(metadata=) must not perform HTTP") |
| 66 | |
| 67 | async def post(self, *args, **kwargs): |
| 68 | raise AssertionError("read_global_company_state(metadata=) must not perform HTTP") |
| 69 | |
| 70 | |
| 71 | def _orchestrator(repo: ResearchRepository) -> PortfolioResearchOrchestrator: |
| 72 | settings = Settings( |
| 73 | research_demo_enabled=False, |
| 74 | structured_provider_enabled=False, |
| 75 | portfolio_service_base_url="http://portfolio-service", |
| 76 | ) |
| 77 | return PortfolioResearchOrchestrator(repo, settings, client=_OfflineClient()) |
| 78 | |
| 79 | |
| 80 | def test_global_presentation_keeps_verified_yahoo_mapping() -> None: |
| 81 | global_id = uuid4() |
| 82 | repo = ResearchRepository(settings=Settings(research_demo_enabled=False)) |
| 83 | orchestrator = _orchestrator(repo) |
| 84 | payload = _global_instrument_payload(global_id, yahoo_status="VERIFIED") |
| 85 | |
| 86 | company = asyncio.run(orchestrator.read_global_company_state(global_id, metadata=payload)) |
| 87 | assert company.verified_provider_mappings == { |
| 88 | "NSE": "VENUSPIPES", |
| 89 | "YAHOO_FINANCE": "VENUSPIPES.NS", |
| 90 | } |
| 91 | |
| 92 | |
| 93 | def test_global_presentation_drops_drifted_unverified_yahoo_mapping_from_summary_and_readiness() -> None: |
| 94 | global_id = uuid4() |
| 95 | repo = ResearchRepository(settings=Settings(research_demo_enabled=False)) |
| 96 | orchestrator = _orchestrator(repo) |
| 97 | verified = _global_instrument_payload(global_id, yahoo_status="VERIFIED") |
| 98 | drifted = _global_instrument_payload(global_id, yahoo_status="INVALID") |
| 99 | |
| 100 | # Seed a process-local profile whose Yahoo mapping was VERIFIED. |
| 101 | assert orchestrator.register_global_profile_metadata(global_id, verified) is True |
| 102 | assert repo.profile(global_id).provider_instrument_ids == { |
| 103 | "NSE": "VENUSPIPES", |
| 104 | "YAHOO_FINANCE": "VENUSPIPES.NS", |
| 105 | } |
| 106 | |
| 107 | # The instrument now reports the Yahoo mapping as INVALID. The presentation |
| 108 | # projection (read_global_company_state) must not retain the stale, now-unverified |
| 109 | # Yahoo mapping as verified -- it must agree with the readiness projection. |
| 110 | company = asyncio.run(orchestrator.read_global_company_state(global_id, metadata=drifted)) |
| 111 | assert "YAHOO_FINANCE" not in company.verified_provider_mappings |
| 112 | assert company.verified_provider_mappings == {"NSE": "VENUSPIPES"} |
| 113 | |
| 114 | # The readiness projection agrees after its own refresh. |
| 115 | assert orchestrator.register_global_profile_metadata(global_id, drifted) is True |
| 116 | assert "YAHOO_FINANCE" not in repo.profile(global_id).provider_instrument_ids |
| 117 | |
| 118 | |
| 119 | def test_global_presentation_never_guesses_a_yahoo_symbol() -> None: |
| 120 | global_id = uuid4() |
| 121 | repo = ResearchRepository(settings=Settings(research_demo_enabled=False)) |
| 122 | orchestrator = _orchestrator(repo) |
| 123 | payload = _no_yahoo_payload(global_id) |
| 124 | |
| 125 | company = asyncio.run(orchestrator.read_global_company_state(global_id, metadata=payload)) |
| 126 | assert "YAHOO_FINANCE" not in company.verified_provider_mappings |
| 127 | assert company.verified_provider_mappings == {"NSE": "NOYAHOO"} |