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).
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user