Files
CIAgent/apps/api/tests/collectors/test_patents_collector.py
T
saksham 1a4c80958f Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring,
source collection, LLM-based change analysis, enrichment, and account
security (Turnstile, escalating lockout, email verification).
2026-08-05 10:48:20 -04:00

161 lines
6.1 KiB
Python

"""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