"""PatentSourceCollector's live USPTO Open Data Portal branch - only active when `Settings.uspto_api_key` is configured. The no-key disabled/ fixture path is covered separately in test_fixture_collectors.py and is asserted here to stay unchanged. Live-verified (2026-08): USPTO's Patent Application Search has no queryable assignee/company field at all, so `collect()` searches by each of the company's known leadership names (from NinjaPear enrichment) instead - see the module docstring in app/collectors/patents.py. """ from __future__ import annotations import httpx import pytest import respx from app.collectors.base import CompanyContext, SourceConfig from app.collectors.patents import PatentSourceCollector from app.core.config import Settings from app.models.enums import SourceStatus, SourceType COMPANY = CompanyContext( id="c1", name="Acme Mobility", official_website=None, monitoring_focus=None ) COMPANY_WITH_LEADERSHIP = CompanyContext( id="c1", name="Acme Mobility", official_website=None, monitoring_focus=None, leadership_names=["Jane Doe", "John Smith"], ) def _with_key(monkeypatch, key: str = "test-uspto-key") -> None: monkeypatch.setattr("app.collectors.patents.get_settings", lambda: Settings(uspto_api_key=key)) @pytest.mark.asyncio async def test_discover_returns_empty_without_a_key(): discovered = await PatentSourceCollector().discover(COMPANY) assert discovered == [] @pytest.mark.asyncio async def test_discover_returns_a_source_when_a_key_is_configured(monkeypatch): _with_key(monkeypatch) discovered = await PatentSourceCollector().discover(COMPANY) assert len(discovered) == 1 assert discovered[0].source_type == SourceType.PATENT @pytest.mark.asyncio async def test_collect_with_no_leadership_names_is_an_empty_result_with_no_network_call( monkeypatch, ): """No names to search USPTO's inventor index with (enrichment never ran, still pending, or found no leadership) - an honest empty result, not a wasted/failed call.""" _with_key(monkeypatch) source = SourceConfig( id="s1", source_type=SourceType.PATENT, name="Acme Patents", base_url=None ) with respx.mock: route = respx.post("https://api.uspto.gov/api/v1/patent/applications/search") result = await PatentSourceCollector().collect(source, COMPANY) assert route.call_count == 0 assert result.status == SourceStatus.ACTIVE assert result.documents == [] @pytest.mark.asyncio async def test_collect_searches_by_each_leadership_name_and_dedupes(monkeypatch): _with_key(monkeypatch) source = SourceConfig( id="s1", source_type=SourceType.PATENT, name="Acme Patents", base_url=None ) # title/filingDate/abstractText live under applicationMetaData, not at # the entry's top level - matches the real API's confirmed live shape. shared_patent = { "applicationNumberText": "18/123456", "applicationMetaData": { "inventionTitle": "Battery Swapping System for Electric Vehicles", "filingDate": "2026-01-15", "abstractText": "A system for rapid battery exchange in electric scooters.", }, } with respx.mock: route = respx.post("https://api.uspto.gov/api/v1/patent/applications/search").mock( return_value=httpx.Response(200, json={"patentFileWrapperDataBag": [shared_patent]}) ) result = await PatentSourceCollector().collect(source, COMPANY_WITH_LEADERSHIP) # Both names searched (2 requests)... assert route.call_count == 2 assert route.calls[0].request.headers["x-api-key"] == "test-uspto-key" for call in route.calls: assert b"inventorNameText" in call.request.content # ...but the same application number returned by both searches is # only kept once. assert result.status == SourceStatus.ACTIVE assert len(result.documents) == 1 doc = result.documents[0] assert doc.metadata["application_number"] == "18/123456" assert doc.metadata["match_type"] == "leadership_name_heuristic" assert doc.trust_score == 0.5 assert "Battery Swapping" in doc.title assert "heuristic" in doc.content_text @pytest.mark.asyncio async def test_no_matching_records_for_a_name_is_not_a_failure(monkeypatch): """USPTO returns 404 for "no matching records" rather than a 200 with an empty array - a real, expected outcome for most names, not a collection failure.""" _with_key(monkeypatch) source = SourceConfig( id="s1", source_type=SourceType.PATENT, name="Acme Patents", base_url=None ) with respx.mock: respx.post("https://api.uspto.gov/api/v1/patent/applications/search").mock( return_value=httpx.Response(404, json={"message": "No matching records found"}) ) result = await PatentSourceCollector().collect(source, COMPANY_WITH_LEADERSHIP) assert result.status == SourceStatus.ACTIVE assert result.documents == [] @pytest.mark.asyncio async def test_collect_surfaces_a_real_api_error_when_every_search_fails(monkeypatch): """Once a key is configured, a real failure must be reported honestly - not silently masked as the "no provider configured" disabled state, which would look like the user's key was never even attempted.""" _with_key(monkeypatch) source = SourceConfig( id="s1", source_type=SourceType.PATENT, name="Acme Patents", base_url=None ) with respx.mock: respx.post("https://api.uspto.gov/api/v1/patent/applications/search").mock( return_value=httpx.Response(401, text="Invalid API key") ) result = await PatentSourceCollector().collect(source, COMPANY_WITH_LEADERSHIP) assert result.status == SourceStatus.FAILED assert "401" in result.error @pytest.mark.asyncio async def test_collect_without_a_key_is_unchanged_disabled_behavior(): source = SourceConfig( id="s1", source_type=SourceType.PATENT, name="Acme Patents", base_url=None ) result = await PatentSourceCollector().collect(source, COMPANY_WITH_LEADERSHIP) assert result.status == SourceStatus.DISABLED assert "does not fabricate" in result.error