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).
161 lines
6.1 KiB
Python
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
|