| 1 | from uuid import uuid4 |
| 2 | from unittest.mock import AsyncMock |
| 3 | import httpx |
| 4 | import pytest |
| 5 | from fastapi.testclient import TestClient |
| 6 | from app import main |
| 7 | from app.models import StructuredInstrumentResolution |
| 8 | from app.structured_market import StructuredProviderError |
| 9 | from test_structured_market import provider, response, instrument |
| 10 | |
| 11 | @pytest.mark.asyncio |
| 12 | @pytest.mark.parametrize('symbol,isin,name', [('ALPHA','INE111A01010','Alpha Components Limited'),('BETA','INE222A01010','Beta Engineering Limited'),('GAMMA','INE333A01010','Gamma Services Limited')]) |
| 13 | async def test_trusted_official_identity_resolves_generically_without_research_acquisition(symbol,isin,name): |
| 14 | calls=[] |
| 15 | def handler(request): |
| 16 | calls.append(request.url.path) |
| 17 | assert request.url.params['newsCount']=='0' |
| 18 | return response({'quotes':[{'symbol':symbol+'.NS','exchange':'NSI','quoteType':'EQUITY','longname':name,'isin':isin}]}) |
| 19 | resolved=await provider(handler).resolve_instrument(instrument(canonicalName=name,isin=isin,structuredNseCandidateTicker=symbol+'.NS',structuredNseCandidateSource='VERIFIED_NSE')) |
| 20 | assert resolved.provider_ticker==symbol+'.NS' |
| 21 | assert resolved.status=='VERIFIED_NSE_CANDIDATE' |
| 22 | assert calls==['/v1/finance/search'] |
| 23 | |
| 24 | @pytest.mark.asyncio |
| 25 | @pytest.mark.parametrize('case',['mismatched_isin','ambiguous','unavailable']) |
| 26 | async def test_mapping_validation_fails_closed(case): |
| 27 | quote={'symbol':'ALPHA.NS','exchange':'NSI','quoteType':'EQUITY','longname':'Alpha Components Limited','isin':'INE111A01010'} |
| 28 | if case=='mismatched_isin':quote['isin']='INE222A01010' |
| 29 | def handler(request):return response({'quotes':[quote,quote] if case=='ambiguous' else [quote]},503 if case=='unavailable' else 200) |
| 30 | with pytest.raises(StructuredProviderError,match='UNAVAILABLE' if case=='unavailable' else 'COMPANY_NOT_RESOLVED'): |
| 31 | await provider(handler).resolve_instrument(instrument(canonicalName='Alpha Components Limited',isin='INE111A01010',structuredNseCandidateTicker='ALPHA.NS',structuredNseCandidateSource='VERIFIED_NSE')) |
| 32 | |
| 33 | def test_internal_identity_route_never_collects_snapshot(monkeypatch): |
| 34 | from datetime import datetime, timezone |
| 35 | resolver=AsyncMock(return_value=StructuredInstrumentResolution(instrument_id=uuid4(),provider='YAHOO_FINANCE',provider_ticker='ALPHA.NS',company_name='Alpha Components Limited',confidence=.95,resolved_at=datetime.now(timezone.utc),status='VERIFIED_NSE_CANDIDATE')) |
| 36 | collect=AsyncMock(side_effect=AssertionError('Research acquisition forbidden')) |
| 37 | monkeypatch.setattr(main.portfolio_orchestrator.structured_provider,'resolve_instrument',resolver) |
| 38 | monkeypatch.setattr(main.portfolio_orchestrator.structured_provider,'collect',collect) |
| 39 | payload={'assetType':'EQUITY','isin':'INE111A01010','structuredNseCandidateSource':'VERIFIED_NSE','structuredNseCandidateTicker':'ALPHA.NS'} |
| 40 | client=TestClient(main.app) |
| 41 | assert client.post('/internal/v1/research/instruments/resolve-provider',json=payload).status_code==403 |
| 42 | assert client.post('/internal/v1/research/instruments/resolve-provider',json=payload,headers={'X-AIP-Service-Identity':'portfolio-service'}).status_code==200 |
| 43 | collect.assert_not_called() |
| 44 | |
| 45 | @pytest.mark.asyncio |
| 46 | async def test_targeted_ensure_uses_mapping_added_after_initial_canonical_read(): |
| 47 | from app.repository import ResearchRepository |
| 48 | from app.persistence import SqliteResearchPersistence |
| 49 | from app.settings import Settings |
| 50 | from app.portfolio_orchestration import PortfolioResearchOrchestrator |
| 51 | from app.research_readiness_runtime import ResearchReadinessRuntime, RepositoryResearchReadinessAdapter |
| 52 | from app.yahoo_mcp_acquisition import McpFirstResearchCapabilityExecutor |
| 53 | from test_yahoo_mcp_acquisition import INSTRUMENT_ID, FakeGateway, FakeLegacy |
| 54 | repository=ResearchRepository(settings=Settings(research_live_enabled=False,research_demo_enabled=False),persistence=SqliteResearchPersistence()) |
| 55 | orchestrator=PortfolioResearchOrchestrator(repository,repository.settings) |
| 56 | metadata={'globalInstrumentId':str(INSTRUMENT_ID),'canonicalName':'Ready Limited','isin':'INE111A01010','assetType':'EQUITY','currency':'INR','country':'IN','primaryExchange':'NSE','primarySymbol':'READY','status':'ACTIVE','providerMappings':[{'provider':'NSE','providerSymbol':'READY','status':'VERIFIED','exchange':'NSE','currency':'INR'}]} |
| 57 | assert orchestrator.register_global_profile_metadata(INSTRUMENT_ID,metadata) |
| 58 | assert 'YAHOO_FINANCE' not in repository.profile(INSTRUMENT_ID).provider_instrument_ids |
| 59 | metadata['providerMappings'].append({'provider':'YAHOO_FINANCE','providerSymbol':'READY.NS','status':'VERIFIED','exchange':'NSE','currency':'INR','resolutionSource':'YAHOO_FROM_VERIFIED_NSE'}) |
| 60 | assert orchestrator.register_global_profile_metadata(INSTRUMENT_ID,metadata) |
| 61 | class VerifiedGateway(FakeGateway): |
| 62 | async def acquire_requirement(self,profile,**kwargs): |
| 63 | assert profile.instrument_id==INSTRUMENT_ID |
| 64 | assert profile.provider_instrument_ids['YAHOO_FINANCE']=='READY.NS' |
| 65 | return await super().acquire_requirement(profile,**kwargs) |
| 66 | gateway=VerifiedGateway() |
| 67 | legacy=FakeLegacy() |
| 68 | executor=McpFirstResearchCapabilityExecutor(legacy,repository,gateway,enabled=True) |
| 69 | runtime=ResearchReadinessRuntime(repository,RepositoryResearchReadinessAdapter(repository),executor) |
| 70 | result=await runtime.ensure(INSTRUMENT_ID,jurisdiction='INDIA',requirement_ids=['LATEST_PRICE']) |
| 71 | assert 'VERIFIED_YAHOO_MAPPING_REQUIRED' not in str(result.failures) |
| 72 | assert gateway.calls |
| 73 | assert repository.market_price_observations_for({INSTRUMENT_ID})[INSTRUMENT_ID] |
| 74 | assert repository.financial_facts_for(INSTRUMENT_ID)==[] |