| 1 | from types import SimpleNamespace |
| 2 | from uuid import UUID |
| 3 | import pytest |
| 4 | from app.news_acquisition import acquire_news |
| 5 | from app.source_discovery import CandidateSearchResult, DisabledSearchDiscoveryProvider |
| 6 | from app.persistence import SqliteResearchPersistence |
| 7 | from app.news_intelligence import SearchRun, EventImpactFeature |
| 8 | from app.business_exposure import extract_profile, CompanyBusinessExposureProfile |
| 9 | from test_news_intelligence_v2 import NOW, KEY, document, profile |
| 10 | |
| 11 | |
| 12 | class Provider: |
| 13 | def __init__(self,name='web',results=(),fail=False,degraded=False): |
| 14 | self.provider_name=name; self.results=results; self.fail=fail; self.calls=[]; self.last_query_degraded=degraded |
| 15 | async def discover(self,company,category,window): |
| 16 | self.calls.append(window.explicit_queries) |
| 17 | if self.fail: raise RuntimeError('SECRET_MUST_NOT_ESCAPE') |
| 18 | return list(self.results) |
| 19 | |
| 20 | |
| 21 | class Repository: |
| 22 | def __init__(self): |
| 23 | self.settings=SimpleNamespace(market_data_population_request_interval_seconds=1.25) |
| 24 | self._persistence=SqliteResearchPersistence(); self.documents={}; self.fetches=[] |
| 25 | d=document('Our principal raw material is copper.','OFFICIAL_COMPANY') |
| 26 | d.document_id=UUID(int=83); d.canonical_url='https://issuer.test/report' |
| 27 | self.documents[d.document_id]=d; self._persistence.upsert_document(d) |
| 28 | self._fetcher=SimpleNamespace(fetch=self.fetch) |
| 29 | def documents_for(self,*a,**k): return list(self.documents.values()) |
| 30 | def structured_market_snapshots_for(self,keys): return {k:[] for k in keys} |
| 31 | async def append_news_record(self,value): return self._persistence.append_news_record(value) |
| 32 | async def _run_blocking_persistence(self,fn,*args): return fn(*args) |
| 33 | async def fetch(self,url): |
| 34 | self.fetches.append(url) |
| 35 | return SimpleNamespace(text='<html><title>Copper reaches record high</title><body>Pressure builds on cable makers.</body></html>', |
| 36 | content_type='text/html',final_url=url) |
| 37 | |
| 38 | |
| 39 | def company(): |
| 40 | from test_research_readiness_runtime import _profile |
| 41 | return _profile(KEY).model_copy(update={'company_id':KEY,'company_name':'Generic Cable Limited','ticker':'GCBL'}) |
| 42 | def candidate(url='https://publisher.test/news'): |
| 43 | return CandidateSearchResult('Copper reaches record high',url,'snippet is not evidence',NOW,'web','id','copper wires','CURRENT_NEWS') |
| 44 | async def no_sleep(seconds): pass |
| 45 | |
| 46 | |
| 47 | @pytest.mark.asyncio |
| 48 | @pytest.mark.parametrize('kind,expected',[('empty','SEARCH_COMPLETE_NO_EVENTS'),('fail','SEARCH_FAILED'),('disabled','SEARCH_FAILED'),('degraded','SEARCH_PARTIAL')]) |
| 49 | async def test_worker_search_outcomes(kind,expected): |
| 50 | provider=DisabledSearchDiscoveryProvider() if kind=='disabled' else Provider(fail=kind=='fail',degraded=kind=='degraded') |
| 51 | repo=Repository() |
| 52 | run,features=await acquire_news(repo,company(),providers=[provider],now=NOW,sleep=no_sleep) |
| 53 | assert run.outcome==expected and features==[] |
| 54 | assert 'SECRET' not in run.model_dump_json() |
| 55 | assert repo._persistence.load_news_records(SearchRun,KEY,as_of=NOW)==[run] |
| 56 | |
| 57 | |
| 58 | @pytest.mark.asyncio |
| 59 | async def test_worker_exposure_queries_persist_features_and_bounded_spacing(): |
| 60 | repo=Repository(); provider=Provider(results=[candidate()]); spacing=[] |
| 61 | async def sleep(seconds): spacing.append(seconds) |
| 62 | run,features=await acquire_news(repo,company(),providers=[provider],industry='Wires and cables',now=NOW,sleep=sleep,max_queries=6) |
| 63 | assert run.outcome=='SEARCH_COMPLETE_WITH_EVENTS' |
| 64 | assert features and features[0].relevance_type=='SECTOR_EXPOSURE' |
| 65 | assert any('copper' in q[0].lower() for q in provider.calls) |
| 66 | assert len(provider.calls)==6 and spacing==[1.25]*6 |
| 67 | assert len(repo.fetches)==1 |
| 68 | assert len(repo._persistence.load_news_records(EventImpactFeature,KEY,as_of=NOW))==1 |
| 69 | again,second=await acquire_news(repo,company(),providers=[provider],industry='Wires and cables',now=NOW,sleep=no_sleep,max_queries=6) |
| 70 | assert len(repo._persistence.load_news_records(EventImpactFeature,KEY,as_of=NOW))==1 |
| 71 | assert features==second |
| 72 | |
| 73 | |
| 74 | @pytest.mark.asyncio |
| 75 | @pytest.mark.parametrize('failure',['fetch','budget']) |
| 76 | async def test_document_incompleteness_never_certifies_no_events(failure): |
| 77 | repo=Repository(); provider=Provider(results=[candidate(),candidate('https://publisher.test/second')]) |
| 78 | if failure=='fetch': |
| 79 | async def broken(url): raise RuntimeError('private error') |
| 80 | repo._fetcher.fetch=broken |
| 81 | run,_=await acquire_news(repo,company(),providers=[provider],now=NOW,sleep=no_sleep,max_documents=1) |
| 82 | assert run.outcome=='SEARCH_PARTIAL' |
| 83 | |
| 84 | |
| 85 | @pytest.mark.asyncio |
| 86 | async def test_empty_yahoo_does_not_short_circuit_web(): |
| 87 | repo=Repository(); yahoo=Provider('yahoo'); web=Provider('web',[candidate()]) |
| 88 | result,features=await acquire_news(repo,company(),providers=[yahoo,web],now=NOW,sleep=no_sleep) |
| 89 | assert result.outcome=='SEARCH_COMPLETE_WITH_EVENTS' and features |
| 90 | assert yahoo.calls and web.calls |
| 91 | |
| 92 | |
| 93 | def test_labelled_profile_fields_and_negation(): |
| 94 | p=profile('Products: cables, wires. Business segments: industrial. Competitors: Other Ltd. We use copper.') |
| 95 | assert p.products_services==['cables','wires'] |
| 96 | assert p.business_segments==['industrial'] |
| 97 | assert p.competitor_names==['Other Ltd'] |
| 98 | from app.news_intelligence import extract_impacts |
| 99 | assert not extract_impacts(p,document('Generic Cable Limited denies confirmed fraud','OFFICIAL_COMPANY'),now=NOW) |
| 100 | |
| 101 | |
| 102 | @pytest.mark.asyncio |
| 103 | async def test_persisted_yahoo_empty_plus_web_events_requires_no_yahoo_call(): |
| 104 | repo=Repository() |
| 105 | repo.acquisition_observations_for=lambda key:[dict(requirement_id='CURRENT_NEWS',provider='YAHOO_FINANCE_MCP', |
| 106 | observed_at=NOW.isoformat(),outcome='SUCCESS_EMPTY')] |
| 107 | result,features=await acquire_news(repo,company(),providers=[Provider(results=[candidate()])],now=NOW,sleep=no_sleep) |
| 108 | assert result.outcome=='SEARCH_COMPLETE_WITH_EVENTS' and features |
| 109 | assert {p.provider:p.outcome for p in result.providers}['YAHOO_FINANCE_MCP']=='SUCCESS_EMPTY' |
| 110 | |
| 111 | |
| 112 | def test_competitor_action_in_same_document_not_attributed_to_company(): |
| 113 | from app.news_intelligence import extract_impacts |
| 114 | assert not extract_impacts(profile(),document('Generic Cable Limited reported sales. Other Manufacturer confirmed fraud.'),now=NOW) |