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,24 @@
|
||||
"""Collector tests never touch the real network or real DNS - every
|
||||
hostname resolves to a fixed public IP, and respx intercepts the actual
|
||||
HTTP layer per test."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.collectors import robots as robots_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_dns():
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_robots_cache():
|
||||
robots_module.clear_cache()
|
||||
yield
|
||||
robots_module.clear_cache()
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.collectors.base import CompanyContext, SourceConfig
|
||||
from app.collectors.custom_url import CustomUrlCollector
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
COMPANY = CompanyContext(id="c1", name="Acme Corp", official_website=None, monitoring_focus=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_extracts_single_page():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.CUSTOM_URL,
|
||||
name="Pricing page",
|
||||
base_url="https://example.com/pricing",
|
||||
)
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/pricing").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
html=(
|
||||
"<html><head><title>Pricing</title></head><body>"
|
||||
"<article><h1>Pricing</h1><p>Plans start at $99/month for the base tier.</p>"
|
||||
"</article></body></html>"
|
||||
),
|
||||
)
|
||||
)
|
||||
result = await CustomUrlCollector().collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.ACTIVE
|
||||
assert len(result.documents) == 1
|
||||
assert "$99/month" in result.documents[0].content_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_reports_auth_required_on_403():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.CUSTOM_URL,
|
||||
name="Gated page",
|
||||
base_url="https://example.com/gated",
|
||||
)
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/gated").mock(return_value=httpx.Response(403))
|
||||
result = await CustomUrlCollector().collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.AUTH_REQUIRED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_respects_robots_disallow():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.CUSTOM_URL,
|
||||
name="Disallowed page",
|
||||
base_url="https://example.com/private",
|
||||
)
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(
|
||||
return_value=httpx.Response(200, text="User-agent: *\nDisallow: /private\n")
|
||||
)
|
||||
result = await CustomUrlCollector().collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.BLOCKED_BY_POLICY
|
||||
assert result.documents == []
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Patent/review collectors: documented fixture adapters that must never
|
||||
fabricate data when no provider or fixture is configured."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.collectors.base import CompanyContext, SourceConfig
|
||||
from app.collectors.patents import PatentSourceCollector
|
||||
from app.collectors.reviews import ReviewSourceCollector
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
COMPANY = CompanyContext(
|
||||
id="c1", name="Acme Mobility Systems", official_website=None, monitoring_focus=None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patent_collector_disabled_without_fixture_key():
|
||||
source = SourceConfig(id="s1", source_type=SourceType.PATENT, name="Patents", base_url=None)
|
||||
result = await PatentSourceCollector().collect(source, COMPANY)
|
||||
assert result.status == SourceStatus.DISABLED
|
||||
assert result.documents == []
|
||||
assert "does not fabricate" in result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patent_collector_disabled_for_unknown_fixture_key():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.PATENT,
|
||||
name="Patents",
|
||||
base_url=None,
|
||||
configuration_metadata={"fixture_key": "does_not_exist"},
|
||||
)
|
||||
result = await PatentSourceCollector().collect(source, COMPANY)
|
||||
assert result.status == SourceStatus.DISABLED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patent_collector_loads_fixture_when_configured():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.PATENT,
|
||||
name="Patents",
|
||||
base_url=None,
|
||||
configuration_metadata={"fixture_key": "acme_mobility"},
|
||||
)
|
||||
result = await PatentSourceCollector().collect(source, COMPANY)
|
||||
assert result.status == SourceStatus.ACTIVE
|
||||
assert len(result.documents) == 1
|
||||
assert result.documents[0].metadata["is_fixture"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_collector_disabled_without_fixture_key():
|
||||
source = SourceConfig(id="s1", source_type=SourceType.REVIEW, name="Reviews", base_url=None)
|
||||
result = await ReviewSourceCollector().collect(source, COMPANY)
|
||||
assert result.status == SourceStatus.DISABLED
|
||||
assert result.documents == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_collector_loads_fixture_when_configured():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.REVIEW,
|
||||
name="Reviews",
|
||||
base_url=None,
|
||||
configuration_metadata={"fixture_key": "acme_mobility"},
|
||||
)
|
||||
result = await ReviewSourceCollector().collect(source, COMPANY)
|
||||
assert result.status == SourceStatus.ACTIVE
|
||||
assert len(result.documents) == 2
|
||||
assert all(d.metadata["is_fixture"] for d in result.documents)
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.collectors.base import CompanyContext, SourceConfig
|
||||
from app.collectors.github import GithubCollector
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
COMPANY = CompanyContext(id="c1", name="Acme Corp", official_website=None, monitoring_focus=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_finds_org_login():
|
||||
with respx.mock:
|
||||
respx.get("https://api.github.com/search/users").mock(
|
||||
return_value=httpx.Response(200, text=json.dumps({"items": [{"login": "acme-corp"}]}))
|
||||
)
|
||||
discovered = await GithubCollector().discover(COMPANY)
|
||||
|
||||
assert discovered[0].configuration_metadata["org"] == "acme-corp"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_returns_repo_documents():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.GITHUB,
|
||||
name="Acme GitHub",
|
||||
base_url="https://github.com/acme-corp",
|
||||
configuration_metadata={"org": "acme-corp"},
|
||||
)
|
||||
repos = [
|
||||
{
|
||||
"full_name": "acme-corp/battery-sdk",
|
||||
"description": "SDK for battery management systems",
|
||||
"language": "Python",
|
||||
"stargazers_count": 42,
|
||||
"pushed_at": "2026-05-01T12:00:00Z",
|
||||
"html_url": "https://github.com/acme-corp/battery-sdk",
|
||||
"archived": False,
|
||||
}
|
||||
]
|
||||
with respx.mock:
|
||||
respx.get("https://api.github.com/orgs/acme-corp/repos").mock(
|
||||
return_value=httpx.Response(200, text=json.dumps(repos))
|
||||
)
|
||||
result = await GithubCollector().collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.ACTIVE
|
||||
assert len(result.documents) == 1
|
||||
assert "battery management" in result.documents[0].content_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_reports_rate_limited_on_403():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.GITHUB,
|
||||
name="Acme GitHub",
|
||||
base_url=None,
|
||||
configuration_metadata={"org": "acme-corp"},
|
||||
)
|
||||
with respx.mock:
|
||||
respx.get("https://api.github.com/orgs/acme-corp/repos").mock(
|
||||
return_value=httpx.Response(403)
|
||||
)
|
||||
result = await GithubCollector().collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.RATE_LIMITED
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.collectors.base import CompanyContext, SourceConfig
|
||||
from app.collectors.gov_contracts import GovContractCollector
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
COMPANY = CompanyContext(id="c1", name="Acme Corp", official_website=None, monitoring_focus=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_always_returns_a_source_without_a_network_call():
|
||||
with respx.mock:
|
||||
discovered = await GovContractCollector().discover(COMPANY)
|
||||
|
||||
assert len(discovered) == 1
|
||||
assert discovered[0].source_type == SourceType.GOV_CONTRACT
|
||||
assert discovered[0].configuration_metadata["recipient_search_text"] == "Acme Corp"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_maps_awards_to_documents():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.GOV_CONTRACT,
|
||||
name="Acme Federal Contracts",
|
||||
base_url=None,
|
||||
configuration_metadata={"recipient_search_text": "Acme Corp"},
|
||||
)
|
||||
payload = {
|
||||
"results": [
|
||||
{
|
||||
"Award ID": "W91CRB-26-C-0001",
|
||||
"Recipient Name": "Acme Corp",
|
||||
"Award Amount": 4_500_000,
|
||||
"Start Date": "2026-02-01",
|
||||
"Awarding Agency": "Department of Defense",
|
||||
"Description": "Supply of tactical equipment.",
|
||||
}
|
||||
]
|
||||
}
|
||||
with respx.mock:
|
||||
respx.post("https://api.usaspending.gov/api/v2/search/spending_by_award/").mock(
|
||||
return_value=httpx.Response(200, json=payload)
|
||||
)
|
||||
result = await GovContractCollector().collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.ACTIVE
|
||||
assert len(result.documents) == 1
|
||||
doc = result.documents[0]
|
||||
assert doc.metadata["award_id"] == "W91CRB-26-C-0001"
|
||||
assert "Department of Defense" in doc.title
|
||||
assert "$4,500,000" in doc.title
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_zero_awards_is_not_a_failure():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.GOV_CONTRACT,
|
||||
name="Acme Federal Contracts",
|
||||
base_url=None,
|
||||
configuration_metadata={"recipient_search_text": "Acme Corp"},
|
||||
)
|
||||
with respx.mock:
|
||||
respx.post("https://api.usaspending.gov/api/v2/search/spending_by_award/").mock(
|
||||
return_value=httpx.Response(200, json={"results": []})
|
||||
)
|
||||
result = await GovContractCollector().collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.ACTIVE
|
||||
assert result.documents == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_fails_gracefully_on_http_error():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.GOV_CONTRACT,
|
||||
name="Acme Federal Contracts",
|
||||
base_url=None,
|
||||
configuration_metadata={"recipient_search_text": "Acme Corp"},
|
||||
)
|
||||
with respx.mock:
|
||||
respx.post("https://api.usaspending.gov/api/v2/search/spending_by_award/").mock(
|
||||
return_value=httpx.Response(500, text="internal error")
|
||||
)
|
||||
result = await GovContractCollector().collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.FAILED
|
||||
assert "500" in result.error
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.collectors.base import CompanyContext, SourceConfig
|
||||
from app.collectors.jobs import JobPostingCollector
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
CAREERS_HTML = """
|
||||
<html><body>
|
||||
<h1>Careers at Acme</h1>
|
||||
<ul>
|
||||
<li><a href="/careers/job/battery-engineer">Senior Battery Engineer</a></li>
|
||||
<li><a href="/careers/job/battery-technician">Battery Systems Technician</a></li>
|
||||
<li><a href="/about">About us</a></li>
|
||||
</ul>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
COMPANY = CompanyContext(
|
||||
id="c1", name="Acme Corp", official_website="https://example.com", monitoring_focus=None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_targets_careers_page():
|
||||
discovered = await JobPostingCollector().discover(COMPANY)
|
||||
assert discovered[0].base_url == "https://example.com/careers"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_extracts_job_listings_and_ignores_unrelated_links():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.JOB_POSTING,
|
||||
name="Acme Careers",
|
||||
base_url="https://example.com/careers",
|
||||
)
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/careers").mock(
|
||||
return_value=httpx.Response(200, html=CAREERS_HTML)
|
||||
)
|
||||
result = await JobPostingCollector().collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.ACTIVE
|
||||
titles = {d.title for d in result.documents}
|
||||
assert titles == {"Senior Battery Engineer", "Battery Systems Technician"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_falls_back_to_whole_page_when_no_job_links_found():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.JOB_POSTING,
|
||||
name="Acme Careers",
|
||||
base_url="https://example.com/careers",
|
||||
)
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/careers").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
html="<html><body><article><h1>Careers</h1><p>We use an external ATS widget.</p></article></body></html>",
|
||||
)
|
||||
)
|
||||
result = await JobPostingCollector().collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.ACTIVE
|
||||
assert len(result.documents) == 1
|
||||
assert result.documents[0].metadata["extraction"] == "fallback_whole_page"
|
||||
@@ -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
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.collectors.base import CompanyContext, SourceConfig
|
||||
from app.collectors.rss import RssCollector
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
FEED_XML = """<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Acme News</title>
|
||||
<item>
|
||||
<title>Acme opens new facility</title>
|
||||
<link>https://example.com/news/1</link>
|
||||
<description>Acme Corp opened a new manufacturing facility this week.</description>
|
||||
<pubDate>Mon, 01 Jun 2026 10:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>Acme hires new VP</title>
|
||||
<link>https://example.com/news/2</link>
|
||||
<description>Acme Corp announced a new VP of Engineering.</description>
|
||||
</item>
|
||||
</channel></rss>
|
||||
"""
|
||||
|
||||
COMPANY = CompanyContext(id="c1", name="Acme Corp", official_website=None, monitoring_focus=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_builds_a_google_news_search_url():
|
||||
discovered = await RssCollector().discover(COMPANY)
|
||||
|
||||
assert len(discovered) == 1
|
||||
assert discovered[0].source_type == SourceType.RSS
|
||||
assert discovered[0].base_url.startswith("https://news.google.com/rss/search?q=")
|
||||
assert "Acme%20Corp" in discovered[0].base_url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_url_encodes_ampersands_in_the_name():
|
||||
company = CompanyContext(id="c1", name="AT&T", official_website=None, monitoring_focus=None)
|
||||
discovered = await RssCollector().discover(company)
|
||||
|
||||
# A raw "&" from the company name must not leak into the query string,
|
||||
# since it would be parsed as a new URL parameter separator (splitting
|
||||
# the query in two) rather than part of the search text.
|
||||
query_part = discovered[0].base_url.split("q=", 1)[1].split("&hl=", 1)[0]
|
||||
assert "&" not in query_part
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_parses_feed_items():
|
||||
source = SourceConfig(
|
||||
id="s1", source_type=SourceType.RSS, name="Acme RSS", base_url="https://example.com/feed"
|
||||
)
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/feed").mock(return_value=httpx.Response(200, text=FEED_XML))
|
||||
result = await RssCollector().collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.ACTIVE
|
||||
assert len(result.documents) == 2
|
||||
assert result.documents[0].title == "Acme opens new facility"
|
||||
assert result.documents[0].publication_date is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_fails_gracefully_on_http_error():
|
||||
source = SourceConfig(
|
||||
id="s1", source_type=SourceType.RSS, name="Acme RSS", base_url="https://example.com/feed"
|
||||
)
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/feed").mock(return_value=httpx.Response(500))
|
||||
result = await RssCollector().collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.FAILED
|
||||
assert result.documents == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_without_base_url_fails_without_network_call():
|
||||
source = SourceConfig(id="s1", source_type=SourceType.RSS, name="Acme RSS", base_url=None)
|
||||
result = await RssCollector().collect(source, COMPANY)
|
||||
assert result.status == SourceStatus.FAILED
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.collectors.base import CompanyContext, SourceConfig
|
||||
from app.collectors.sec_edgar import SecEdgarCollector
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
ATOM_RESPONSE = """<?xml version="1.0"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<title>ACME CORP</title>
|
||||
<content type="text">CIK=0000320193&ScoreKeyed=true</content>
|
||||
</entry>
|
||||
</feed>
|
||||
"""
|
||||
|
||||
COMPANY = CompanyContext(id="c1", name="Acme Corp", official_website=None, monitoring_focus=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_finds_cik_from_atom_search():
|
||||
with respx.mock:
|
||||
respx.get("https://www.sec.gov/cgi-bin/browse-edgar").mock(
|
||||
return_value=httpx.Response(200, text=ATOM_RESPONSE)
|
||||
)
|
||||
discovered = await SecEdgarCollector().discover(COMPANY)
|
||||
|
||||
assert len(discovered) == 1
|
||||
assert discovered[0].configuration_metadata["cik"] == "0000320193"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_returns_empty_when_no_match():
|
||||
with respx.mock:
|
||||
respx.get("https://www.sec.gov/cgi-bin/browse-edgar").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text='<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>'
|
||||
)
|
||||
)
|
||||
discovered = await SecEdgarCollector().discover(COMPANY)
|
||||
|
||||
assert discovered == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_extracts_relevant_filings():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.SEC_EDGAR,
|
||||
name="Acme EDGAR",
|
||||
base_url=None,
|
||||
configuration_metadata={"cik": "0000320193"},
|
||||
)
|
||||
submissions = {
|
||||
"name": "Acme Corp",
|
||||
"filings": {
|
||||
"recent": {
|
||||
"form": ["10-K", "S-1", "8-K"],
|
||||
"filingDate": ["2026-02-01", "2026-01-15", "2026-03-01"],
|
||||
"accessionNumber": [
|
||||
"0000320193-26-000001",
|
||||
"0000320193-26-000002",
|
||||
"0000320193-26-000003",
|
||||
],
|
||||
"primaryDocument": ["10k.htm", "s1.htm", "8k.htm"],
|
||||
}
|
||||
},
|
||||
}
|
||||
with respx.mock:
|
||||
respx.get("https://data.sec.gov/submissions/CIK0000320193.json").mock(
|
||||
return_value=httpx.Response(200, text=json.dumps(submissions))
|
||||
)
|
||||
result = await SecEdgarCollector().collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.ACTIVE
|
||||
forms = {d.metadata["form"] for d in result.documents}
|
||||
assert forms == {"10-K", "8-K"} # S-1 filtered out - not in _RELEVANT_FORMS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_without_cik_fails_without_network_call():
|
||||
source = SourceConfig(
|
||||
id="s1", source_type=SourceType.SEC_EDGAR, name="Acme EDGAR", base_url=None
|
||||
)
|
||||
result = await SecEdgarCollector().collect(source, COMPANY)
|
||||
assert result.status == SourceStatus.FAILED
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.collectors.base import CompanyContext, SourceConfig
|
||||
from app.collectors.website import WebsiteCollector
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
ARTICLE_HTML = """
|
||||
<html><body>
|
||||
<nav>Home | About</nav>
|
||||
<article>
|
||||
<h1>{title}</h1>
|
||||
<p>{body}</p>
|
||||
</article>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
COMPANY = CompanyContext(
|
||||
id="c1", name="Acme Corp", official_website="https://example.com", monitoring_focus=None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_uses_sitemap_when_available():
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/sitemap.xml").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
content=(
|
||||
'<?xml version="1.0"?>'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
||||
"<url><loc>https://example.com/about</loc></url>"
|
||||
"<url><loc>https://example.com/products</loc></url>"
|
||||
"</urlset>"
|
||||
),
|
||||
)
|
||||
)
|
||||
collector = WebsiteCollector()
|
||||
discovered = await collector.discover(COMPANY)
|
||||
|
||||
assert len(discovered) == 1
|
||||
pages = discovered[0].configuration_metadata["pages"]
|
||||
assert "https://example.com/about" in pages
|
||||
assert "https://example.com/products" in pages
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_falls_back_to_heuristic_paths_without_sitemap():
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/sitemap.xml").mock(return_value=httpx.Response(404))
|
||||
collector = WebsiteCollector()
|
||||
discovered = await collector.discover(COMPANY)
|
||||
|
||||
pages = discovered[0].configuration_metadata["pages"]
|
||||
assert any(p.endswith("/about") for p in pages)
|
||||
assert any(p.endswith("/careers") for p in pages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_extracts_documents_from_configured_pages():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.WEBSITE,
|
||||
name="Acme website",
|
||||
base_url="https://example.com",
|
||||
configuration_metadata={
|
||||
"pages": ["https://example.com/about", "https://example.com/products"]
|
||||
},
|
||||
)
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/about").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
html=ARTICLE_HTML.format(
|
||||
title="About Acme", body="Acme Corp builds electric utility vehicles."
|
||||
),
|
||||
)
|
||||
)
|
||||
respx.get("https://example.com/products").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
html=ARTICLE_HTML.format(
|
||||
title="Products", body="Our product line includes trucks and vans."
|
||||
),
|
||||
)
|
||||
)
|
||||
collector = WebsiteCollector()
|
||||
result = await collector.collect(source, COMPANY)
|
||||
|
||||
assert result.status == SourceStatus.ACTIVE
|
||||
assert len(result.documents) == 2
|
||||
assert any("electric utility vehicles" in d.content_text for d in result.documents)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_skips_pages_disallowed_by_robots_txt():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.WEBSITE,
|
||||
name="Acme website",
|
||||
base_url="https://example.com",
|
||||
configuration_metadata={"pages": ["https://example.com/private"]},
|
||||
)
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(
|
||||
return_value=httpx.Response(200, text="User-agent: *\nDisallow: /private\n")
|
||||
)
|
||||
collector = WebsiteCollector()
|
||||
result = await collector.collect(source, COMPANY)
|
||||
|
||||
assert result.documents == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_deduplicates_identical_content_across_pages():
|
||||
source = SourceConfig(
|
||||
id="s1",
|
||||
source_type=SourceType.WEBSITE,
|
||||
name="Acme website",
|
||||
base_url="https://example.com",
|
||||
configuration_metadata={"pages": ["https://example.com/a", "https://example.com/a-mirror"]},
|
||||
)
|
||||
same_html = ARTICLE_HTML.format(title="Same", body="Identical content on both URLs.")
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/a").mock(return_value=httpx.Response(200, html=same_html))
|
||||
respx.get("https://example.com/a-mirror").mock(
|
||||
return_value=httpx.Response(200, html=same_html)
|
||||
)
|
||||
collector = WebsiteCollector()
|
||||
result = await collector.collect(source, COMPANY)
|
||||
|
||||
assert len(result.documents) == 1
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Shared pytest fixtures.
|
||||
|
||||
Tests always run with LLM_PROVIDER=mock, SEARCH_PROVIDER=mock, a throwaway
|
||||
SQLite DB, and Celery in eager mode - never against a paid provider or a
|
||||
real network target.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("APP_ENV", "test")
|
||||
os.environ.setdefault("AUTH_MODE", "jwt")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///./test_ciagent.db")
|
||||
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15")
|
||||
os.environ.setdefault("LLM_PROVIDER", "mock")
|
||||
os.environ.setdefault("SEARCH_PROVIDER", "mock")
|
||||
os.environ.setdefault("CELERY_TASK_ALWAYS_EAGER", "true")
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret-please-change-32-characters")
|
||||
# No artificial per-domain delay in tests - the collector tests hit many
|
||||
# distinct mocked hostnames and shouldn't pay the real-world crawl-politeness cost.
|
||||
os.environ.setdefault("SCRAPER_DOMAIN_DELAY_SECONDS", "0")
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.db.base import Base
|
||||
from app.db.session import get_engine, get_sessionmaker
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _setup_database():
|
||||
# The DB is a throwaway file-based SQLite DB (aiosqlite doesn't support
|
||||
# ":memory:" across the multiple connections a test session opens), so it
|
||||
# must be deleted up front - otherwise companies/runs left behind by a
|
||||
# previous test session accumulate and pollute count-based assertions
|
||||
# (e.g. the scheduler "how many companies got enqueued" tests).
|
||||
db_path = Path("test_ciagent.db")
|
||||
db_path.unlink(missing_ok=True)
|
||||
|
||||
async def _create() -> None:
|
||||
engine = get_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
asyncio.run(_create())
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def settings() -> Settings:
|
||||
return get_settings()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> TestClient:
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
async def db_session():
|
||||
"""Raw AsyncSession for tests that exercise services/repositories
|
||||
directly rather than through the HTTP API."""
|
||||
session_factory = get_sessionmaker()
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def local_mode_client(settings: Settings) -> TestClient:
|
||||
"""A client with AUTH_MODE forced to "local" via dependency override,
|
||||
independent of whatever AUTH_MODE the rest of the suite runs under - and
|
||||
with its TCP peer set to loopback, since the local-dev-user bypass
|
||||
(app.auth.dependencies.get_current_user) only fires for a genuinely
|
||||
local request, not merely AUTH_MODE=local. Starlette's TestClient
|
||||
defaults to a fake ("testclient", 50000) peer otherwise."""
|
||||
local_settings = settings.model_copy(update={"auth_mode": "local"})
|
||||
app.dependency_overrides[get_settings] = lambda: local_settings
|
||||
with TestClient(app, client=("127.0.0.1", 51234)) as c:
|
||||
yield c
|
||||
app.dependency_overrides.pop(get_settings, None)
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.enrichment.mock import MockEnrichmentProvider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_provider_returns_honest_empties_for_everything():
|
||||
provider = MockEnrichmentProvider()
|
||||
|
||||
details = await provider.get_company_details("Acme Corp", "https://acme.example.com")
|
||||
assert details.description is None
|
||||
assert details.leadership_team == []
|
||||
|
||||
funding = await provider.get_funding("Acme Corp", None)
|
||||
assert funding.total_raised is None
|
||||
assert funding.rounds == []
|
||||
|
||||
assert await provider.get_updates("Acme Corp", None) == []
|
||||
assert await provider.get_competitors("Acme Corp", None) == []
|
||||
assert await provider.get_products("Acme Corp", None) == []
|
||||
assert await provider.get_customers("Acme Corp", None) == []
|
||||
assert await provider.get_work_email("Jane Doe", "https://acme.example.com") is None
|
||||
assert await provider.get_person_profile("Jane Doe", "https://acme.example.com") == (None, None)
|
||||
@@ -0,0 +1,186 @@
|
||||
"""NinjaPearProvider against the real API shape documented at
|
||||
nubela.co/llms-full.txt: website-only company identification, and the
|
||||
vendor's actual response field names (executives/employee_count,
|
||||
total_funds_raised/funding_rounds, competitors keyed by website, three
|
||||
separately-categorized customer/investor/partner arrays, first_name+domain
|
||||
for work-email, x_profile_url for the person-profile endpoint)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.enrichment.ninjapear import NinjaPearProvider
|
||||
|
||||
_SETTINGS = Settings(ninjapear_api_key="test-key")
|
||||
_WEBSITE = "https://acme.example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_company_details_parses_executives_and_employee_count():
|
||||
payload = {
|
||||
"description": "A payments company.",
|
||||
"industry": "Fintech",
|
||||
"founded_year": 2010,
|
||||
"specialties": ["Payments", "APIs"],
|
||||
"employee_count": "1001-5000",
|
||||
"executives": [
|
||||
{"name": "Jane Doe", "title": "CEO"},
|
||||
{"name": "", "title": "Ignored - no name"},
|
||||
],
|
||||
}
|
||||
with respx.mock:
|
||||
respx.get("https://nubela.co/api/v1/company/details").mock(
|
||||
return_value=httpx.Response(200, json=payload)
|
||||
)
|
||||
details = await NinjaPearProvider(_SETTINGS).get_company_details("Acme Corp", _WEBSITE)
|
||||
|
||||
assert details.industry == "Fintech"
|
||||
assert details.employee_count_range == "1001-5000"
|
||||
assert len(details.leadership_team) == 1
|
||||
assert details.leadership_team[0].name == "Jane Doe"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_company_details_raises_without_a_website():
|
||||
with pytest.raises(ValueError, match="website"):
|
||||
await NinjaPearProvider(_SETTINGS).get_company_details("Acme Corp", None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_funding_maps_total_funds_raised_and_funding_rounds():
|
||||
# total_funds_raised/amount_usd are raw numbers, and each investor is
|
||||
# an object (not a plain string) - matches the real API's shape.
|
||||
payload = {
|
||||
"total_funds_raised_usd": 500_000_000,
|
||||
"funding_rounds": [
|
||||
{
|
||||
"round_type": "Series C",
|
||||
"amount_usd": 200_000_000,
|
||||
"date": "2024-01-01",
|
||||
"investors": [{"name": "VC Co", "type": "company", "website": None}],
|
||||
}
|
||||
],
|
||||
}
|
||||
with respx.mock:
|
||||
respx.get("https://nubela.co/api/v1/company/funding").mock(
|
||||
return_value=httpx.Response(200, json=payload)
|
||||
)
|
||||
funding = await NinjaPearProvider(_SETTINGS).get_funding("Acme Corp", _WEBSITE)
|
||||
|
||||
assert funding.total_raised == "500000000"
|
||||
assert funding.rounds[0].round_name == "Series C"
|
||||
assert funding.rounds[0].amount == "200000000"
|
||||
assert funding.rounds[0].investors == ["VC Co"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_competitors_falls_back_to_website_when_no_name_given():
|
||||
payload = {
|
||||
"competitors": [{"website": "rival.example.com", "competition_reason": "Same market"}]
|
||||
}
|
||||
with respx.mock:
|
||||
respx.get("https://nubela.co/api/v1/competitor/listing").mock(
|
||||
return_value=httpx.Response(200, json=payload)
|
||||
)
|
||||
competitors = await NinjaPearProvider(_SETTINGS).get_competitors("Acme Corp", _WEBSITE)
|
||||
|
||||
assert competitors[0].name == "rival.example.com"
|
||||
assert competitors[0].reason == "Same market"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_customers_merges_the_three_categorized_arrays():
|
||||
payload = {
|
||||
"customers": [{"name": "BigCo"}],
|
||||
"investors": [{"name": "VC Fund"}],
|
||||
"partner_platforms": [{"name": "PlatformCo"}],
|
||||
}
|
||||
with respx.mock:
|
||||
respx.get("https://nubela.co/api/v1/customer/listing").mock(
|
||||
return_value=httpx.Response(200, json=payload)
|
||||
)
|
||||
customers = await NinjaPearProvider(_SETTINGS).get_customers("Acme Corp", _WEBSITE)
|
||||
|
||||
by_relationship = {c.relationship: c.name for c in customers}
|
||||
assert by_relationship == {"customer": "BigCo", "investor": "VC Fund", "partner": "PlatformCo"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_products_joins_categories():
|
||||
payload = {
|
||||
"products": [
|
||||
{"name": "Widget", "description": "A widget", "categories": ["Hardware", "IoT"]}
|
||||
]
|
||||
}
|
||||
with respx.mock:
|
||||
respx.get("https://nubela.co/api/v1/product/listing").mock(
|
||||
return_value=httpx.Response(200, json=payload)
|
||||
)
|
||||
products = await NinjaPearProvider(_SETTINGS).get_products("Acme Corp", _WEBSITE)
|
||||
|
||||
assert products[0].name == "Widget"
|
||||
assert products[0].category == "Hardware, IoT"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_updates_maps_title_and_source():
|
||||
payload = {
|
||||
"updates": [
|
||||
{
|
||||
"title": "We shipped a feature",
|
||||
"source": "blog",
|
||||
"url": "https://x",
|
||||
"timestamp": "2026-01-01",
|
||||
}
|
||||
]
|
||||
}
|
||||
with respx.mock:
|
||||
respx.get("https://nubela.co/api/v1/company/updates").mock(
|
||||
return_value=httpx.Response(200, json=payload)
|
||||
)
|
||||
updates = await NinjaPearProvider(_SETTINGS).get_updates("Acme Corp", _WEBSITE)
|
||||
|
||||
assert updates[0].text == "We shipped a feature"
|
||||
assert updates[0].type == "blog"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_work_email_sends_first_last_name_and_domain():
|
||||
with respx.mock:
|
||||
route = respx.get("https://nubela.co/api/v1/employee/work-email").mock(
|
||||
return_value=httpx.Response(200, json={"work_email": "[email protected]"})
|
||||
)
|
||||
email = await NinjaPearProvider(_SETTINGS).get_work_email("Jane Doe", _WEBSITE)
|
||||
|
||||
assert email == "[email protected]"
|
||||
sent_params = dict(route.calls[0].request.url.params)
|
||||
assert sent_params == {"first_name": "Jane", "last_name": "Doe", "domain": "acme.example.com"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_person_profile_hits_the_v2_endpoint_and_maps_x_profile_url():
|
||||
with respx.mock:
|
||||
respx.get("https://nubela.co/api/v2/employee/profile").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"x_profile_url": "https://x.com/janedoe", "bio": "A bio."}
|
||||
)
|
||||
)
|
||||
profile_url, bio = await NinjaPearProvider(_SETTINGS).get_person_profile(
|
||||
"Jane Doe", _WEBSITE
|
||||
)
|
||||
|
||||
assert profile_url == "https://x.com/janedoe"
|
||||
assert bio == "A bio."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_provider_error_response_raises_rather_than_being_swallowed():
|
||||
with respx.mock:
|
||||
respx.get("https://nubela.co/api/v1/company/details").mock(
|
||||
return_value=httpx.Response(429, text="rate limited")
|
||||
)
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await NinjaPearProvider(_SETTINGS).get_company_details("Acme Corp", _WEBSITE)
|
||||
@@ -0,0 +1,13 @@
|
||||
<html>
|
||||
<head><title>About Acme Mobility Systems</title></head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>About Us</h1>
|
||||
<p>Acme Mobility Systems is a mobility technology startup based in Austin, Texas, founded in 2019.
|
||||
We build fleet management software for micromobility operators running e-scooter and e-bike fleets
|
||||
across North America.</p>
|
||||
<p>Our CEO is Maria Chen, who co-founded the company after a decade in urban transportation planning.</p>
|
||||
<p>Acme Mobility Systems is headquartered in Austin with a satellite engineering office in Denver.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<html>
|
||||
<head><title>Careers - Acme Mobility Systems</title></head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Open Positions</h1>
|
||||
<ul>
|
||||
<li><a href="/careers/backend-engineer">Backend Engineer</a> - Austin, TX. Build the APIs behind FleetOS.</li>
|
||||
<li><a href="/careers/field-operations-lead">Field Operations Lead</a> - Denver, CO. Own our vehicle rebalancing operations.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<html>
|
||||
<head><title>Press - Acme Mobility Systems</title></head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Press & News</h1>
|
||||
<h2>March 2024 - Acme Mobility raises $8M Series A</h2>
|
||||
<p>Acme Mobility Systems today announced an $8M Series A financing round led by Northside Ventures,
|
||||
with participation from existing seed investors. The funding will be used to expand FleetOS into
|
||||
new metro markets.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<html>
|
||||
<head><title>Pricing - Acme Mobility Systems</title></head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Pricing</h1>
|
||||
<p>FleetOS Starter plan: $499/month for up to 200 vehicles. Includes telemetry, rebalancing
|
||||
routing, and email support.</p>
|
||||
<p>FleetOS Enterprise: custom pricing for fleets over 1,000 vehicles.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<html>
|
||||
<head><title>Products - Acme Mobility Systems</title></head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Products</h1>
|
||||
<p>Our flagship product, FleetOS, helps micromobility operators manage e-scooter and e-bike fleets:
|
||||
vehicle telemetry, battery-swap routing, and dynamic rebalancing.</p>
|
||||
<p>FleetOS integrates with most major vehicle hardware vendors via an open telemetry API.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<html>
|
||||
<head><title>About Acme Mobility Systems</title></head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>About Us</h1>
|
||||
<p>Acme Mobility Systems is a mobility technology startup based in Austin, Texas, founded in 2019.
|
||||
We build fleet management software for micromobility operators running e-scooter and e-bike fleets
|
||||
across North America.</p>
|
||||
<p>In June 2026, James Okafor was named the company's new Chief Executive Officer, succeeding
|
||||
co-founder Maria Chen, who will remain on the board as Chair.</p>
|
||||
<p>Acme Mobility Systems is headquartered in Austin with a satellite engineering office in Denver.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<html>
|
||||
<head><title>Careers - Acme Mobility Systems</title></head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Open Positions</h1>
|
||||
<ul>
|
||||
<li><a href="/careers/backend-engineer">Backend Engineer</a> - Austin, TX. Build the APIs behind FleetOS.</li>
|
||||
<li><a href="/careers/field-operations-lead">Field Operations Lead</a> - Denver, CO. Own our vehicle rebalancing operations.</li>
|
||||
<li><a href="/careers/senior-ml-engineer">Senior Machine Learning Engineer</a> - Austin, TX. Join our new AI-powered fleet rebalancing team, building predictive demand models for FleetOS.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<html>
|
||||
<head><title>Press - Acme Mobility Systems</title></head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Press & News</h1>
|
||||
<h2>June 2026 - Acme Mobility announces expansion into 12 new cities</h2>
|
||||
<p>Acme Mobility Systems today announced it is expanding FleetOS operations into 12 new metro
|
||||
markets across the Southeast and Midwest by the end of the year, doubling its operating footprint.</p>
|
||||
<h2>March 2024 - Acme Mobility raises $8M Series A</h2>
|
||||
<p>Acme Mobility Systems today announced an $8M Series A financing round led by Northside Ventures,
|
||||
with participation from existing seed investors. The funding will be used to expand FleetOS into
|
||||
new metro markets.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<html>
|
||||
<head><title>Pricing - Acme Mobility Systems</title></head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Pricing</h1>
|
||||
<p>FleetOS Starter plan: $999/month for up to 200 vehicles. Includes telemetry, rebalancing
|
||||
routing, and email support.</p>
|
||||
<p>FleetOS Enterprise: custom pricing for fleets over 1,000 vehicles.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<html>
|
||||
<head><title>Products - Acme Mobility Systems</title></head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Products</h1>
|
||||
<p>Our flagship product, FleetOS, helps micromobility operators manage e-scooter and e-bike fleets:
|
||||
vehicle telemetry, battery-swap routing, and dynamic rebalancing.</p>
|
||||
<p>FleetOS integrates with most major vehicle hardware vendors via an open telemetry API.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"patents": [
|
||||
{
|
||||
"title": "Battery thermal management system for electric drivetrains",
|
||||
"assignee": "Acme Mobility Systems",
|
||||
"abstract": "A thermal management system for regulating temperature in high-density battery packs used in electric vehicle drivetrains, improving charge cycle longevity.",
|
||||
"filed_date": "2026-03-14",
|
||||
"url": "https://patents.example/acme-mobility/US-2026-000123"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"reviews": [
|
||||
{
|
||||
"title": "Reliable fleet vehicles",
|
||||
"author": "Fleet Operator",
|
||||
"rating": 4,
|
||||
"date": "2026-05-02",
|
||||
"body": "We've run Acme's gasoline utility trucks for two years with minimal downtime. Parts availability is good.",
|
||||
"url": "https://reviews.example/acme-mobility/review-1"
|
||||
},
|
||||
{
|
||||
"title": "Support could be faster",
|
||||
"author": "Small Business Owner",
|
||||
"rating": 3,
|
||||
"date": "2026-04-18",
|
||||
"body": "Vehicles are solid but the support line has long wait times during peak season.",
|
||||
"url": "https://reviews.example/acme-mobility/review-2"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Regression test for the collection + change-detection pipeline using the
|
||||
Acme Mobility Systems v1/v2 fixture HTML under tests/fixtures/acme_mobility/,
|
||||
read straight off disk and run through the real pipeline end-to-end - so a
|
||||
change to those fixtures, or a regression in the pipeline, breaks a test
|
||||
here rather than going unnoticed. No live network: respx mocks every HTTP
|
||||
call for a fictitious acme-mobility.example host."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.company import Company
|
||||
from app.models.enums import ChangeType, SourceType
|
||||
from app.models.monitor_configuration import MonitorConfiguration
|
||||
from app.repositories.source_repository import SnapshotRepository, SourceRepository
|
||||
from app.services import collection_service
|
||||
from app.services.change_detection_service import detect_change_for_source
|
||||
|
||||
_FIXTURE_ROOT = Path(__file__).resolve().parent.parent / "fixtures" / "acme_mobility"
|
||||
_HOST = "https://acme-mobility.example"
|
||||
_PAGES = ["about", "products", "careers", "press", "pricing"]
|
||||
|
||||
|
||||
def _page_html(version: str, page: str) -> str:
|
||||
return (_FIXTURE_ROOT / version / f"{page}.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_dns():
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
yield
|
||||
|
||||
|
||||
async def _make_company(db_session) -> Company:
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
name="Acme Mobility Systems (Demo)",
|
||||
slug=f"acme-mobility-demo-{uuid.uuid4().hex[:6]}",
|
||||
official_website=f"{_HOST}/about",
|
||||
monitoring_focus="leadership changes, pricing, hiring, expansion",
|
||||
)
|
||||
db_session.add(company)
|
||||
db_session.add(MonitorConfiguration(company_id=company.id))
|
||||
await db_session.commit()
|
||||
result = await db_session.execute(
|
||||
select(Company)
|
||||
.where(Company.id == company.id)
|
||||
.options(
|
||||
selectinload(Company.aliases),
|
||||
selectinload(Company.competitors),
|
||||
selectinload(Company.enrichment),
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
def _mock_version(version: str):
|
||||
respx.get(f"{_HOST}/robots.txt").mock(return_value=httpx.Response(404))
|
||||
for page in _PAGES:
|
||||
respx.get(f"{_HOST}/{page}").mock(
|
||||
return_value=httpx.Response(200, html=_page_html(version, page))
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acme_v1_to_v2_produces_the_expected_change_types(db_session, settings):
|
||||
company = await _make_company(db_session)
|
||||
repo = SourceRepository(db_session)
|
||||
sources = {}
|
||||
for page in _PAGES:
|
||||
sources[page] = await repo.create(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.CUSTOM_URL,
|
||||
name=f"Acme Mobility - {page.title()}",
|
||||
base_url=f"{_HOST}/{page}",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
# Baseline run (v1): every source collects successfully, no prior
|
||||
# snapshot to diff against yet.
|
||||
with respx.mock:
|
||||
_mock_version("v1")
|
||||
for page in _PAGES:
|
||||
result = await collection_service.collect_source(
|
||||
db_session, settings, sources[page], company
|
||||
)
|
||||
assert result.status.value == "active", f"{page} baseline collection failed"
|
||||
|
||||
# Second run (v2): about/careers/press/pricing changed, products did not.
|
||||
with respx.mock:
|
||||
_mock_version("v2")
|
||||
for page in _PAGES:
|
||||
await collection_service.collect_source(db_session, settings, sources[page], company)
|
||||
|
||||
snapshot_repo = SnapshotRepository(db_session)
|
||||
changes: dict[str, ChangeType | None] = {}
|
||||
for page in _PAGES:
|
||||
current = await snapshot_repo.latest_for_source(sources[page].id)
|
||||
change = await detect_change_for_source(
|
||||
db_session, sources[page], company, current, uuid.uuid4()
|
||||
)
|
||||
changes[page] = change.change_type if change else None
|
||||
|
||||
assert changes["about"] == ChangeType.LEADERSHIP_CHANGE
|
||||
assert changes["pricing"] == ChangeType.PRICE_CHANGE
|
||||
assert changes["products"] is None # identical content -> hash short-circuit, no change
|
||||
assert changes["careers"] is not None # new job listing -> detected as a real change
|
||||
assert changes["press"] is not None # new press entry -> detected as a real change
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Alert creation end-to-end against a real (SQLite) DB: a DetectedChange
|
||||
above the company's threshold becomes an Alert (via the mock LLM's Task F),
|
||||
gets dispatched to every enabled destination that also meets its own
|
||||
threshold, and a NotificationDelivery is recorded per attempt. Two
|
||||
independent thresholds by design - see alert_service.py docstring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.analysis.llm.mock import MockLLMProvider
|
||||
from app.core.config import Settings
|
||||
from app.models.company import Company
|
||||
from app.models.detected_change import DetectedChange
|
||||
from app.models.enums import (
|
||||
ChangeType,
|
||||
MonitoringRunTrigger,
|
||||
NotificationDeliveryStatus,
|
||||
NotificationType,
|
||||
SeverityLevel,
|
||||
SourceType,
|
||||
)
|
||||
from app.models.monitor_configuration import MonitorConfiguration
|
||||
from app.models.monitoring_run import MonitoringRun
|
||||
from app.models.notification_delivery import NotificationDelivery
|
||||
from app.models.notification_destination import (
|
||||
NotificationDestination,
|
||||
NotificationDestinationCompany,
|
||||
)
|
||||
from app.models.snapshot import Snapshot
|
||||
from app.models.source import Source
|
||||
from app.repositories.company_repository import CompanyRepository
|
||||
from app.services.alert_service import create_alert_for_change, send_test_notification
|
||||
|
||||
|
||||
async def _make_company(
|
||||
db_session, *, severity_threshold: SeverityLevel = SeverityLevel.MEDIUM
|
||||
) -> Company:
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
name="Acme Mobility Systems",
|
||||
slug=f"acme-mobility-{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db_session.add(company)
|
||||
await db_session.flush()
|
||||
|
||||
db_session.add(
|
||||
MonitorConfiguration(company_id=company.id, severity_threshold=severity_threshold)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
return await CompanyRepository(db_session).get_for_user(company.id, company.user_id)
|
||||
|
||||
|
||||
async def _make_change(db_session, company: Company, *, severity: SeverityLevel) -> DetectedChange:
|
||||
source = Source(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.WEBSITE,
|
||||
name="Website",
|
||||
base_url="https://acme.example",
|
||||
)
|
||||
db_session.add(source)
|
||||
await db_session.flush()
|
||||
|
||||
run = MonitoringRun(company_id=company.id, trigger_type=MonitoringRunTrigger.MANUAL)
|
||||
db_session.add(run)
|
||||
await db_session.flush()
|
||||
|
||||
snapshot = Snapshot(
|
||||
company_id=company.id,
|
||||
source_id=source.id,
|
||||
snapshot_type=source.source_type.value,
|
||||
hash="hash1",
|
||||
structured_summary={},
|
||||
text_summary="",
|
||||
monitoring_run_id=run.id,
|
||||
)
|
||||
db_session.add(snapshot)
|
||||
await db_session.flush()
|
||||
|
||||
change = DetectedChange(
|
||||
company_id=company.id,
|
||||
source_id=source.id,
|
||||
monitoring_run_id=run.id,
|
||||
current_snapshot_id=snapshot.id,
|
||||
change_type=ChangeType.NEW_DOCUMENT,
|
||||
raw_diff={
|
||||
"text_added_lines": ["We're hiring a new VP of Engineering."],
|
||||
"structured_added": ["/careers/vp-engineering"],
|
||||
},
|
||||
significance_score=0.7,
|
||||
confidence_score=0.8,
|
||||
severity=severity,
|
||||
summary="New leadership hire posting detected",
|
||||
)
|
||||
db_session.add(change)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(change)
|
||||
return change
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_below_company_threshold_creates_no_alert(db_session, settings: Settings):
|
||||
company = await _make_company(db_session, severity_threshold=SeverityLevel.HIGH)
|
||||
change = await _make_change(db_session, company, severity=SeverityLevel.LOW)
|
||||
|
||||
alert = await create_alert_for_change(db_session, settings, MockLLMProvider(), change, company)
|
||||
|
||||
assert alert is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_at_threshold_creates_alert_with_llm_summary(db_session, settings: Settings):
|
||||
company = await _make_company(db_session, severity_threshold=SeverityLevel.MEDIUM)
|
||||
change = await _make_change(db_session, company, severity=SeverityLevel.HIGH)
|
||||
|
||||
alert = await create_alert_for_change(db_session, settings, MockLLMProvider(), change, company)
|
||||
|
||||
assert alert is not None
|
||||
assert alert.company_id == company.id
|
||||
assert alert.detected_change_id == change.id
|
||||
assert alert.severity == SeverityLevel.HIGH
|
||||
assert alert.confidence == change.confidence_score
|
||||
assert alert.title
|
||||
assert alert.summary
|
||||
assert alert.why_it_matters
|
||||
assert alert.read is False
|
||||
assert alert.resolved is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_dispatches_only_to_destinations_meeting_their_own_threshold(
|
||||
db_session, settings: Settings, monkeypatch
|
||||
):
|
||||
# EMAIL delivery goes through the real SmtpEmailProvider - mock the
|
||||
# socket-level smtplib call rather than depending on a live SMTP
|
||||
# relay (e.g. Mailpit) actually being reachable in the test environment.
|
||||
class FakeSmtp:
|
||||
def __init__(self, host, port, timeout=10):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
pass
|
||||
|
||||
def login(self, username, password):
|
||||
pass
|
||||
|
||||
def sendmail(self, from_addr, to_addrs, message):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
|
||||
|
||||
company = await _make_company(db_session, severity_threshold=SeverityLevel.LOW)
|
||||
change = await _make_change(db_session, company, severity=SeverityLevel.MEDIUM)
|
||||
|
||||
low_bar = NotificationDestination(
|
||||
user_id=company.user_id,
|
||||
type=NotificationType.EMAIL,
|
||||
destination_value="[email protected]",
|
||||
minimum_severity=SeverityLevel.LOW,
|
||||
enabled=True,
|
||||
)
|
||||
high_bar = NotificationDestination(
|
||||
user_id=company.user_id,
|
||||
type=NotificationType.EMAIL,
|
||||
destination_value="[email protected]",
|
||||
minimum_severity=SeverityLevel.CRITICAL,
|
||||
enabled=True,
|
||||
)
|
||||
disabled = NotificationDestination(
|
||||
user_id=company.user_id,
|
||||
type=NotificationType.EMAIL,
|
||||
destination_value="[email protected]",
|
||||
minimum_severity=SeverityLevel.LOW,
|
||||
enabled=False,
|
||||
)
|
||||
db_session.add_all([low_bar, high_bar, disabled])
|
||||
await db_session.commit()
|
||||
db_session.add_all(
|
||||
NotificationDestinationCompany(destination_id=d.id, company_id=company.id)
|
||||
for d in (low_bar, high_bar, disabled)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
alert = await create_alert_for_change(db_session, settings, MockLLMProvider(), change, company)
|
||||
assert alert is not None
|
||||
|
||||
deliveries = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(NotificationDelivery).where(NotificationDelivery.alert_id == alert.id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
assert len(deliveries) == 1
|
||||
assert deliveries[0].destination_id == low_bar.id
|
||||
assert deliveries[0].status == NotificationDeliveryStatus.SENT
|
||||
assert deliveries[0].provider == "smtp"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sms_destination_skipped_when_sms_disabled(db_session, settings: Settings):
|
||||
company = await _make_company(db_session, severity_threshold=SeverityLevel.LOW)
|
||||
change = await _make_change(db_session, company, severity=SeverityLevel.MEDIUM)
|
||||
|
||||
sms_destination = NotificationDestination(
|
||||
user_id=company.user_id,
|
||||
type=NotificationType.SMS,
|
||||
destination_value="+15551234567",
|
||||
minimum_severity=SeverityLevel.LOW,
|
||||
enabled=True,
|
||||
)
|
||||
db_session.add(sms_destination)
|
||||
await db_session.commit()
|
||||
|
||||
disabled_sms_settings = settings.model_copy(update={"notification_sms_enabled": False})
|
||||
alert = await create_alert_for_change(
|
||||
db_session, disabled_sms_settings, MockLLMProvider(), change, company
|
||||
)
|
||||
assert alert is not None
|
||||
|
||||
deliveries = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(NotificationDelivery).where(NotificationDelivery.alert_id == alert.id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert deliveries == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_delivery_is_recorded_with_error(db_session, settings: Settings):
|
||||
company = await _make_company(db_session, severity_threshold=SeverityLevel.LOW)
|
||||
change = await _make_change(db_session, company, severity=SeverityLevel.MEDIUM)
|
||||
|
||||
unconfigured_sms_settings = settings.model_copy(
|
||||
update={
|
||||
"notification_sms_enabled": True,
|
||||
"twilio_account_sid": "",
|
||||
"twilio_auth_token": "",
|
||||
"twilio_from_number": "",
|
||||
}
|
||||
)
|
||||
sms_destination = NotificationDestination(
|
||||
user_id=company.user_id,
|
||||
type=NotificationType.SMS,
|
||||
destination_value="+15551234567",
|
||||
minimum_severity=SeverityLevel.LOW,
|
||||
enabled=True,
|
||||
)
|
||||
db_session.add(sms_destination)
|
||||
await db_session.commit()
|
||||
db_session.add(
|
||||
NotificationDestinationCompany(destination_id=sms_destination.id, company_id=company.id)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
alert = await create_alert_for_change(
|
||||
db_session, unconfigured_sms_settings, MockLLMProvider(), change, company
|
||||
)
|
||||
assert alert is not None
|
||||
|
||||
deliveries = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(NotificationDelivery).where(NotificationDelivery.alert_id == alert.id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(deliveries) == 1
|
||||
assert deliveries[0].status == NotificationDeliveryStatus.FAILED
|
||||
assert "not configured" in deliveries[0].error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_test_notification_short_circuits_sms_when_disabled(
|
||||
db_session, settings: Settings
|
||||
):
|
||||
"""The "Send test notification" button must respect NOTIFICATION_SMS_ENABLED
|
||||
the same way real alert dispatch does - it must never place a real API
|
||||
call to an SMS vendor while SMS delivery is switched off, even though a
|
||||
Twilio/Telnyx-configured provider would otherwise happily send one."""
|
||||
user_id = uuid.uuid4()
|
||||
sms_destination = NotificationDestination(
|
||||
user_id=user_id,
|
||||
type=NotificationType.SMS,
|
||||
destination_value="+15551234567",
|
||||
minimum_severity=SeverityLevel.LOW,
|
||||
enabled=True,
|
||||
)
|
||||
db_session.add(sms_destination)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(sms_destination)
|
||||
|
||||
disabled_sms_settings = settings.model_copy(
|
||||
update={
|
||||
"notification_sms_enabled": False,
|
||||
"sms_provider": "telnyx",
|
||||
"telnyx_api_key": "would-be-a-real-key",
|
||||
"telnyx_from_number": "+15559990000",
|
||||
}
|
||||
)
|
||||
result = await send_test_notification(
|
||||
db_session, disabled_sms_settings, user_id, sms_destination.id
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "disabled" in result.error
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Dashboard analytics: aggregate counts scoped correctly to the requesting
|
||||
user (never leaking another user's data), zero-filled for enum members with
|
||||
no data, and recent signals ordered newest-first."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.alert import Alert
|
||||
from app.models.company import Company
|
||||
from app.models.detected_change import DetectedChange
|
||||
from app.models.enums import (
|
||||
ChangeType,
|
||||
MonitoringRunStatus,
|
||||
MonitoringRunTrigger,
|
||||
SeverityLevel,
|
||||
SourceType,
|
||||
)
|
||||
from app.models.monitoring_run import MonitoringRun
|
||||
from app.models.snapshot import Snapshot
|
||||
from app.models.source import Source
|
||||
from app.services.analytics_service import get_dashboard_analytics
|
||||
|
||||
|
||||
async def _make_company_with_activity(db_session, user_id: uuid.UUID) -> Company:
|
||||
company = Company(
|
||||
id=uuid.uuid4(), user_id=user_id, name="Acme Corp", slug=f"acme-{uuid.uuid4().hex[:6]}"
|
||||
)
|
||||
db_session.add(company)
|
||||
await db_session.flush()
|
||||
|
||||
source = Source(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.WEBSITE,
|
||||
name="Site",
|
||||
base_url="https://acme.example",
|
||||
)
|
||||
db_session.add(source)
|
||||
await db_session.flush()
|
||||
|
||||
run = MonitoringRun(
|
||||
company_id=company.id,
|
||||
trigger_type=MonitoringRunTrigger.MANUAL,
|
||||
status=MonitoringRunStatus.SUCCESSFUL,
|
||||
)
|
||||
db_session.add(run)
|
||||
await db_session.flush()
|
||||
|
||||
snapshot = Snapshot(
|
||||
company_id=company.id,
|
||||
source_id=source.id,
|
||||
snapshot_type=source.source_type.value,
|
||||
hash="h1",
|
||||
structured_summary={},
|
||||
text_summary="",
|
||||
monitoring_run_id=run.id,
|
||||
)
|
||||
db_session.add(snapshot)
|
||||
await db_session.flush()
|
||||
|
||||
change = DetectedChange(
|
||||
company_id=company.id,
|
||||
source_id=source.id,
|
||||
monitoring_run_id=run.id,
|
||||
current_snapshot_id=snapshot.id,
|
||||
change_type=ChangeType.LEADERSHIP_CHANGE,
|
||||
raw_diff={},
|
||||
significance_score=0.6,
|
||||
confidence_score=0.8,
|
||||
severity=SeverityLevel.HIGH,
|
||||
summary="New CEO announced",
|
||||
)
|
||||
db_session.add(change)
|
||||
await db_session.flush()
|
||||
|
||||
alert = Alert(
|
||||
company_id=company.id,
|
||||
detected_change_id=change.id,
|
||||
user_id=user_id,
|
||||
title="Leadership change",
|
||||
summary="New CEO announced",
|
||||
why_it_matters="Signals a strategy shift",
|
||||
severity=SeverityLevel.HIGH,
|
||||
confidence=0.8,
|
||||
)
|
||||
db_session.add(alert)
|
||||
await db_session.commit()
|
||||
return company
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analytics_aggregates_counts_for_the_requesting_user(db_session):
|
||||
user_id = uuid.uuid4()
|
||||
await _make_company_with_activity(db_session, user_id)
|
||||
|
||||
analytics = await get_dashboard_analytics(db_session, user_id)
|
||||
|
||||
assert analytics.changes_by_type["leadership_change"] == 1
|
||||
assert analytics.changes_by_type["price_change"] == 0
|
||||
assert analytics.alerts_by_severity["high"] == 1
|
||||
assert analytics.alerts_by_severity["critical"] == 0
|
||||
assert analytics.sources_by_status["active"] == 1
|
||||
assert len(analytics.recent_signals) == 1
|
||||
assert analytics.recent_signals[0].company_name == "Acme Corp"
|
||||
assert analytics.recent_signals[0].change_type == "leadership_change"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analytics_does_not_leak_another_users_data(db_session):
|
||||
user_id = uuid.uuid4()
|
||||
other_user_id = uuid.uuid4()
|
||||
await _make_company_with_activity(db_session, other_user_id)
|
||||
|
||||
analytics = await get_dashboard_analytics(db_session, user_id)
|
||||
|
||||
assert all(count == 0 for count in analytics.changes_by_type.values())
|
||||
assert all(count == 0 for count in analytics.alerts_by_severity.values())
|
||||
assert analytics.recent_signals == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analytics_recent_signals_ordered_newest_first(db_session):
|
||||
user_id = uuid.uuid4()
|
||||
company = await _make_company_with_activity(db_session, user_id)
|
||||
|
||||
source = Source(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.WEBSITE,
|
||||
name="Site 2",
|
||||
base_url="https://acme.example/2",
|
||||
)
|
||||
db_session.add(source)
|
||||
await db_session.flush()
|
||||
run = MonitoringRun(company_id=company.id, trigger_type=MonitoringRunTrigger.MANUAL)
|
||||
db_session.add(run)
|
||||
await db_session.flush()
|
||||
snapshot = Snapshot(
|
||||
company_id=company.id,
|
||||
source_id=source.id,
|
||||
snapshot_type=source.source_type.value,
|
||||
hash="h2",
|
||||
structured_summary={},
|
||||
text_summary="",
|
||||
monitoring_run_id=run.id,
|
||||
)
|
||||
db_session.add(snapshot)
|
||||
await db_session.flush()
|
||||
newer_change = DetectedChange(
|
||||
company_id=company.id,
|
||||
source_id=source.id,
|
||||
monitoring_run_id=run.id,
|
||||
current_snapshot_id=snapshot.id,
|
||||
change_type=ChangeType.PRICE_CHANGE,
|
||||
raw_diff={},
|
||||
significance_score=0.5,
|
||||
confidence_score=0.7,
|
||||
severity=SeverityLevel.MEDIUM,
|
||||
summary="Price increased",
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db_session.add(newer_change)
|
||||
await db_session.commit()
|
||||
|
||||
analytics = await get_dashboard_analytics(db_session, user_id)
|
||||
|
||||
assert analytics.recent_signals[0].change_type == "price_change"
|
||||
@@ -0,0 +1,372 @@
|
||||
"""End-to-end (DB-backed) tests for change_detection_service: two real
|
||||
Snapshot rows in, a DetectedChange (or None) out. Collectors themselves are
|
||||
already covered elsewhere; this exercises the diff/scoring/persistence
|
||||
pipeline directly against SQLite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.company import Company
|
||||
from app.models.detected_change import DetectedChange
|
||||
from app.models.enums import ChangeStatus, ChangeType, SourceType
|
||||
from app.models.snapshot import Snapshot
|
||||
from app.models.source import Source
|
||||
from app.repositories.source_repository import SnapshotRepository, SourceRepository
|
||||
from app.services.change_detection_service import detect_change_for_source
|
||||
|
||||
|
||||
async def _make_company(db_session, *, monitoring_focus: str | None = None) -> Company:
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
name="Acme Corp",
|
||||
slug=f"acme-corp-{uuid.uuid4().hex[:6]}",
|
||||
monitoring_focus=monitoring_focus,
|
||||
)
|
||||
db_session.add(company)
|
||||
await db_session.commit()
|
||||
result = await db_session.execute(
|
||||
select(Company).where(Company.id == company.id).options(selectinload(Company.aliases))
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def _make_source(
|
||||
db_session, company: Company, *, source_type: SourceType = SourceType.WEBSITE, trust_score=0.8
|
||||
) -> Source:
|
||||
return await SourceRepository(db_session).create(
|
||||
company_id=company.id,
|
||||
source_type=source_type,
|
||||
name="Test Source",
|
||||
base_url="https://example.com",
|
||||
trust_score=trust_score,
|
||||
)
|
||||
|
||||
|
||||
async def _make_snapshot(
|
||||
db_session,
|
||||
company: Company,
|
||||
source: Source,
|
||||
*,
|
||||
content_hash: str,
|
||||
urls: list[str],
|
||||
text_summary: str,
|
||||
created_at: datetime | None = None,
|
||||
) -> Snapshot:
|
||||
snapshot = await SnapshotRepository(db_session).create(
|
||||
company_id=company.id,
|
||||
source_id=source.id,
|
||||
snapshot_type=source.source_type.value,
|
||||
hash=content_hash,
|
||||
structured_summary={"urls": urls, "titles": [], "document_count": len(urls)},
|
||||
text_summary=text_summary,
|
||||
)
|
||||
if created_at is not None:
|
||||
snapshot.created_at = created_at
|
||||
await db_session.commit()
|
||||
return snapshot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_previous_snapshot_produces_no_change(db_session):
|
||||
company = await _make_company(db_session)
|
||||
source = await _make_source(db_session, company)
|
||||
current = await _make_snapshot(
|
||||
db_session, company, source, content_hash="h1", urls=["/a"], text_summary="A"
|
||||
)
|
||||
|
||||
result = await detect_change_for_source(db_session, source, company, current, uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identical_hash_produces_no_change(db_session):
|
||||
company = await _make_company(db_session)
|
||||
source = await _make_source(db_session, company)
|
||||
await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="same-hash",
|
||||
urls=["/a"],
|
||||
text_summary="A",
|
||||
created_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
)
|
||||
current = await _make_snapshot(
|
||||
db_session, company, source, content_hash="same-hash", urls=["/a"], text_summary="A"
|
||||
)
|
||||
|
||||
result = await detect_change_for_source(db_session, source, company, current, uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_item_detected_as_new_document(db_session):
|
||||
company = await _make_company(db_session)
|
||||
source = await _make_source(db_session, company)
|
||||
await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h1",
|
||||
urls=["/careers/job-a"],
|
||||
text_summary="### Job A\nExisting posting.",
|
||||
created_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
)
|
||||
current = await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h2",
|
||||
urls=["/careers/job-a", "/careers/job-b"],
|
||||
text_summary="### Job A\nExisting posting.\n\n### Job B\nNew battery engineer role.",
|
||||
)
|
||||
|
||||
change = await detect_change_for_source(db_session, source, company, current, uuid.uuid4())
|
||||
|
||||
assert change is not None
|
||||
assert change.change_type == ChangeType.NEW_DOCUMENT
|
||||
assert change.status == ChangeStatus.NEW
|
||||
assert "/careers/job-b" in change.raw_diff["structured_added"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leadership_mention_takes_priority_over_new_document(db_session):
|
||||
company = await _make_company(db_session)
|
||||
source = await _make_source(db_session, company)
|
||||
await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h1",
|
||||
urls=["/press/1"],
|
||||
text_summary="### Old release\nRoutine update.",
|
||||
created_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
)
|
||||
current = await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h2",
|
||||
urls=["/press/1", "/press/2"],
|
||||
text_summary=(
|
||||
"### Old release\nRoutine update.\n\n"
|
||||
"### New release\nJane Smith has been named the company's new CEO effective immediately."
|
||||
),
|
||||
)
|
||||
|
||||
change = await detect_change_for_source(db_session, source, company, current, uuid.uuid4())
|
||||
|
||||
# This test is about classification priority (leadership beats the
|
||||
# new-document signal that's also present), not about the resulting
|
||||
# severity number - see test_scoring.py for severity-formula coverage.
|
||||
assert change is not None
|
||||
assert change.change_type == ChangeType.LEADERSHIP_CHANGE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_mention_change_detected(db_session):
|
||||
company = await _make_company(db_session)
|
||||
source = await _make_source(db_session, company)
|
||||
await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h1",
|
||||
urls=["/pricing"],
|
||||
text_summary="### Pricing\nThe base plan is $49/month.",
|
||||
created_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
)
|
||||
current = await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h2",
|
||||
urls=["/pricing"],
|
||||
text_summary="### Pricing\nThe base plan is $59/month.",
|
||||
)
|
||||
|
||||
change = await detect_change_for_source(db_session, source, company, current, uuid.uuid4())
|
||||
|
||||
assert change is not None
|
||||
assert change.change_type == ChangeType.PRICE_CHANGE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sec_edgar_new_filing_detected(db_session):
|
||||
company = await _make_company(db_session)
|
||||
source = await _make_source(db_session, company, source_type=SourceType.SEC_EDGAR)
|
||||
await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h1",
|
||||
urls=["/filings/10-K-2025"],
|
||||
text_summary="### 10-K\nAnnual report.",
|
||||
created_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
)
|
||||
current = await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h2",
|
||||
urls=["/filings/10-K-2025", "/filings/8-K-2026"],
|
||||
text_summary="### 10-K\nAnnual report.\n\n### 8-K\nCurrent report.",
|
||||
)
|
||||
|
||||
change = await detect_change_for_source(db_session, source, company, current, uuid.uuid4())
|
||||
|
||||
assert change is not None
|
||||
assert change.change_type == ChangeType.FILING_NEW
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_content_modified_below_threshold_is_not_reported(db_session):
|
||||
company = await _make_company(db_session)
|
||||
source = await _make_source(db_session, company)
|
||||
# Bounded text diff operates line-by-line - many lines so that changing
|
||||
# one of them is a small fraction of the whole, not "the whole line
|
||||
# differs" (which is what a single long line would produce instead).
|
||||
lines = [f"line {i} says something routine about the company" for i in range(50)]
|
||||
previous_text = "\n".join(lines)
|
||||
await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h1",
|
||||
urls=["/about"],
|
||||
text_summary=previous_text,
|
||||
created_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
)
|
||||
# Change just one line out of 50 - below the content-modified threshold.
|
||||
lines[25] = "line 25 says something slightly different about the company"
|
||||
current = await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h2",
|
||||
urls=["/about"],
|
||||
text_summary="\n".join(lines),
|
||||
)
|
||||
|
||||
change = await detect_change_for_source(db_session, source, company, current, uuid.uuid4())
|
||||
assert change is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_focus_match_is_recorded_via_higher_significance(db_session):
|
||||
matching_company = await _make_company(
|
||||
db_session, monitoring_focus="Watch for battery manufacturing expansion"
|
||||
)
|
||||
other_company = await _make_company(db_session, monitoring_focus="Watch for pricing changes")
|
||||
|
||||
matching_source = await _make_source(db_session, matching_company)
|
||||
other_source = await _make_source(db_session, other_company)
|
||||
|
||||
old_text = "### Careers\nExisting listing."
|
||||
new_text = (
|
||||
"### Careers\nExisting listing.\n\n### New role\nBattery manufacturing engineer wanted."
|
||||
)
|
||||
|
||||
for company, source in ((matching_company, matching_source), (other_company, other_source)):
|
||||
await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h1",
|
||||
urls=["/careers/a"],
|
||||
text_summary=old_text,
|
||||
created_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
)
|
||||
|
||||
matching_current = await _make_snapshot(
|
||||
db_session,
|
||||
matching_company,
|
||||
matching_source,
|
||||
content_hash="h2",
|
||||
urls=["/careers/a", "/careers/b"],
|
||||
text_summary=new_text,
|
||||
)
|
||||
other_current = await _make_snapshot(
|
||||
db_session,
|
||||
other_company,
|
||||
other_source,
|
||||
content_hash="h2",
|
||||
urls=["/careers/a", "/careers/b"],
|
||||
text_summary=new_text,
|
||||
)
|
||||
|
||||
matched_change = await detect_change_for_source(
|
||||
db_session, matching_source, matching_company, matching_current, uuid.uuid4()
|
||||
)
|
||||
unmatched_change = await detect_change_for_source(
|
||||
db_session, other_source, other_company, other_current, uuid.uuid4()
|
||||
)
|
||||
|
||||
assert matched_change.significance_score > unmatched_change.significance_score
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_repeat_within_cooldown_is_suppressed(db_session):
|
||||
company = await _make_company(db_session)
|
||||
source = await _make_source(db_session, company)
|
||||
|
||||
await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h1",
|
||||
urls=["/a"],
|
||||
text_summary="A",
|
||||
created_at=datetime.now(UTC) - timedelta(hours=2),
|
||||
)
|
||||
snap2 = await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h2",
|
||||
urls=["/a", "/b"],
|
||||
text_summary="A\nB",
|
||||
created_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
)
|
||||
first_change = await detect_change_for_source(db_session, source, company, snap2, uuid.uuid4())
|
||||
assert first_change is not None
|
||||
|
||||
# A third snapshot reverts to hash h1's item set momentarily, then a
|
||||
# fourth snapshot reproduces the *exact same* added-item diff as before -
|
||||
# this should be suppressed as a repeat within the cooldown window.
|
||||
await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h1-again",
|
||||
urls=["/a"],
|
||||
text_summary="A",
|
||||
)
|
||||
snap4 = await _make_snapshot(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
content_hash="h2-again",
|
||||
urls=["/a", "/b"],
|
||||
text_summary="A\nB",
|
||||
)
|
||||
second_change = await detect_change_for_source(db_session, source, company, snap4, uuid.uuid4())
|
||||
|
||||
assert second_change is None
|
||||
all_changes = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(DetectedChange).where(DetectedChange.source_id == source.id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(all_changes) == 1
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Integration tests: collection_service persisting collector output through
|
||||
real repositories into a real (SQLite) database. No live network - respx
|
||||
mocks every HTTP call and DNS resolution is patched to a fixed public IP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.company import Company
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
from app.models.monitor_configuration import MonitorConfiguration
|
||||
from app.models.snapshot import Snapshot
|
||||
from app.models.source import Source
|
||||
from app.models.source_document import SourceDocument
|
||||
from app.repositories.source_repository import SourceRepository
|
||||
from app.services import collection_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_dns():
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
yield
|
||||
|
||||
|
||||
async def _make_company(
|
||||
db_session, *, official_website: str | None = "https://example.com"
|
||||
) -> Company:
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
name="Acme Corp",
|
||||
slug=f"acme-corp-{uuid.uuid4().hex[:6]}",
|
||||
official_website=official_website,
|
||||
)
|
||||
db_session.add(company)
|
||||
db_session.add(MonitorConfiguration(company_id=company.id))
|
||||
await db_session.commit()
|
||||
result = await db_session.execute(
|
||||
select(Company)
|
||||
.where(Company.id == company.id)
|
||||
.options(
|
||||
selectinload(Company.aliases),
|
||||
selectinload(Company.competitors),
|
||||
selectinload(Company.enrichment),
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_sources_creates_website_and_job_posting_sources(db_session):
|
||||
company = await _make_company(db_session)
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/sitemap.xml").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://api.github.com/search/users").mock(
|
||||
return_value=httpx.Response(200, text=json.dumps({"items": []}))
|
||||
)
|
||||
respx.get("https://www.sec.gov/cgi-bin/browse-edgar").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text='<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>'
|
||||
)
|
||||
)
|
||||
created = await collection_service.discover_sources_for_company(db_session, company)
|
||||
|
||||
types = {s.source_type for s in created}
|
||||
assert SourceType.WEBSITE in types
|
||||
assert SourceType.JOB_POSTING in types
|
||||
assert SourceType.GITHUB not in types # no org match -> not created
|
||||
assert SourceType.SEC_EDGAR not in types # no CIK match -> not created
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_sources_is_idempotent(db_session):
|
||||
company = await _make_company(db_session)
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/sitemap.xml").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://api.github.com/search/users").mock(
|
||||
return_value=httpx.Response(200, text=json.dumps({"items": []}))
|
||||
)
|
||||
respx.get("https://www.sec.gov/cgi-bin/browse-edgar").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text='<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>'
|
||||
)
|
||||
)
|
||||
first_batch = await collection_service.discover_sources_for_company(db_session, company)
|
||||
second_batch = await collection_service.discover_sources_for_company(db_session, company)
|
||||
|
||||
assert len(first_batch) > 0
|
||||
assert second_batch == [] # already-discovered sources aren't recreated
|
||||
|
||||
repo = SourceRepository(db_session)
|
||||
all_sources = await repo.list_for_company(company.id)
|
||||
assert len(all_sources) == len(first_batch)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_source_persists_documents_and_snapshot(db_session, settings):
|
||||
company = await _make_company(db_session)
|
||||
repo = SourceRepository(db_session)
|
||||
source = await repo.create(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.CUSTOM_URL,
|
||||
name="Pricing page",
|
||||
base_url="https://example.com/pricing",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/pricing").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
html=(
|
||||
"<html><head><title>Pricing</title></head><body>"
|
||||
"<article><h1>Pricing</h1><p>Base plan is $49 per month.</p></article>"
|
||||
"</body></html>"
|
||||
),
|
||||
)
|
||||
)
|
||||
result = await collection_service.collect_source(db_session, settings, source, company)
|
||||
|
||||
assert result.status == SourceStatus.ACTIVE
|
||||
|
||||
docs = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(SourceDocument).where(SourceDocument.source_id == source.id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(docs) == 1
|
||||
assert "$49 per month" in docs[0].content_text
|
||||
|
||||
snapshots = (
|
||||
(await db_session.execute(select(Snapshot).where(Snapshot.source_id == source.id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(snapshots) == 1
|
||||
|
||||
refreshed_source = (
|
||||
await db_session.execute(select(Source).where(Source.id == source.id))
|
||||
).scalar_one()
|
||||
assert refreshed_source.status == SourceStatus.ACTIVE
|
||||
assert refreshed_source.last_successful_check is not None
|
||||
assert refreshed_source.failure_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_source_deduplicates_unchanged_content_across_runs(db_session, settings):
|
||||
company = await _make_company(db_session)
|
||||
repo = SourceRepository(db_session)
|
||||
source = await repo.create(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.CUSTOM_URL,
|
||||
name="About page",
|
||||
base_url="https://example.com/about",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
html = (
|
||||
"<html><head><title>About</title></head><body>"
|
||||
"<article><h1>About</h1><p>We build electric trucks.</p></article></body></html>"
|
||||
)
|
||||
|
||||
for _ in range(2):
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/about").mock(return_value=httpx.Response(200, html=html))
|
||||
await collection_service.collect_source(db_session, settings, source, company)
|
||||
|
||||
docs = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(SourceDocument).where(SourceDocument.source_id == source.id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(docs) == 1 # second run's identical content was deduplicated
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_source_marks_failure_and_increments_count(db_session, settings):
|
||||
company = await _make_company(db_session)
|
||||
repo = SourceRepository(db_session)
|
||||
source = await repo.create(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.CUSTOM_URL,
|
||||
name="Broken page",
|
||||
base_url="https://example.com/broken",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/broken").mock(return_value=httpx.Response(500))
|
||||
result = await collection_service.collect_source(db_session, settings, source, company)
|
||||
|
||||
assert result.status == SourceStatus.FAILED
|
||||
|
||||
refreshed_source = (
|
||||
await db_session.execute(select(Source).where(Source.id == source.id))
|
||||
).scalar_one()
|
||||
assert refreshed_source.failure_count == 1
|
||||
assert refreshed_source.last_successful_check is None
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Company discovery, end-to-end against the mock providers: name in,
|
||||
DiscoveredCompanyProfile out, real evidence flow through search -> fetch ->
|
||||
LLM extraction, with user hints always winning over discovered values. No
|
||||
live network - respx mocks every HTTP call, DNS resolution is patched to a
|
||||
fixed public IP (matching the pattern in test_collection_service.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.analysis.llm.mock import MockLLMProvider
|
||||
from app.models.enums import SourceType
|
||||
from app.search.base import SearchResult
|
||||
from app.search.mock import MockSearchProvider
|
||||
from app.services.discovery_service import _resolve_official_website, discover_company_profile
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_dns():
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
yield
|
||||
|
||||
|
||||
def _mock_empty_github_sec():
|
||||
respx.get("https://api.github.com/search/users").mock(
|
||||
return_value=httpx.Response(200, text=json.dumps({"items": []}))
|
||||
)
|
||||
respx.get("https://www.sec.gov/cgi-bin/browse-edgar").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text='<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_resolves_website_and_extracts_profile_from_real_evidence(settings):
|
||||
search = MockSearchProvider()
|
||||
llm = MockLLMProvider()
|
||||
|
||||
with respx.mock:
|
||||
_mock_empty_github_sec()
|
||||
respx.get("https://acmemobility.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://acmemobility.com").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
html=(
|
||||
"<html><head><title>Acme Mobility</title></head><body>"
|
||||
"<article><h1>About</h1><p>Acme Mobility is headquartered in Austin, Texas. "
|
||||
"Formerly known as Acme Scooters.</p></article></body></html>"
|
||||
),
|
||||
)
|
||||
)
|
||||
profile = await discover_company_profile(
|
||||
search,
|
||||
llm,
|
||||
settings,
|
||||
name="Acme Mobility",
|
||||
official_website=None,
|
||||
monitoring_focus="pricing changes",
|
||||
competitor_names=[],
|
||||
alias_names=[],
|
||||
)
|
||||
|
||||
assert profile.official_website == "https://acmemobility.com"
|
||||
assert profile.headquarters == "Austin, Texas"
|
||||
assert profile.aliases == ["Acme Scooters"]
|
||||
assert profile.monitoring_focus == "pricing changes"
|
||||
assert "https://acmemobility.com" in profile.sources_consulted
|
||||
assert any(p.source_type == SourceType.WEBSITE for p in profile.potential_sources)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_prefers_user_hints_over_discovered_values(settings):
|
||||
search = MockSearchProvider()
|
||||
llm = MockLLMProvider()
|
||||
|
||||
with respx.mock:
|
||||
_mock_empty_github_sec()
|
||||
respx.get("https://acme.example/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://acme.example").mock(
|
||||
return_value=httpx.Response(
|
||||
200, html="<html><body><p>Acme, based in Denver, Colorado.</p></body></html>"
|
||||
)
|
||||
)
|
||||
profile = await discover_company_profile(
|
||||
search,
|
||||
llm,
|
||||
settings,
|
||||
name="Acme",
|
||||
official_website="https://acme.example",
|
||||
monitoring_focus=None,
|
||||
competitor_names=["Rival Corp"],
|
||||
alias_names=["Acme Inc"],
|
||||
)
|
||||
|
||||
# Hint website used as-is (no "official website" search performed for it)
|
||||
assert profile.official_website == "https://acme.example"
|
||||
assert profile.competitors == ["Rival Corp"]
|
||||
assert profile.aliases == ["Acme Inc"]
|
||||
# Still extracted from the real fetched page since that hint wasn't given
|
||||
assert profile.headquarters == "Denver, Colorado"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_handles_a_website_that_fails_to_resolve_gracefully(settings):
|
||||
search = MockSearchProvider()
|
||||
llm = MockLLMProvider()
|
||||
|
||||
with respx.mock:
|
||||
_mock_empty_github_sec()
|
||||
respx.get("https://nowhereco.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://nowhereco.com").mock(return_value=httpx.Response(500))
|
||||
|
||||
profile = await discover_company_profile(
|
||||
search,
|
||||
llm,
|
||||
settings,
|
||||
name="Nowhere Co",
|
||||
official_website=None,
|
||||
monitoring_focus=None,
|
||||
competitor_names=[],
|
||||
alias_names=[],
|
||||
)
|
||||
|
||||
assert profile.official_website == "https://nowhereco.com"
|
||||
assert profile.headquarters is None
|
||||
assert profile.aliases == []
|
||||
|
||||
|
||||
class _StubSearchProvider:
|
||||
"""Returns a fixed result list regardless of query - lets a test control
|
||||
exactly what "official website" search ranking looks like, independent
|
||||
of MockSearchProvider's domain-guessing heuristic."""
|
||||
|
||||
provider_name = "stub"
|
||||
|
||||
def __init__(self, results: list[SearchResult]) -> None:
|
||||
self._results = results
|
||||
|
||||
async def search(self, query: str, *, count: int = 5) -> list[SearchResult]:
|
||||
return self._results[:count]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_official_website_skips_a_top_ranked_wikipedia_result():
|
||||
# Observed live against the real Brave API: "Stripe official website"
|
||||
# ranked Stripe's Wikipedia article above stripe.com itself.
|
||||
search = _StubSearchProvider(
|
||||
[
|
||||
SearchResult(
|
||||
title="Stripe, Inc. - Wikipedia",
|
||||
url="https://en.wikipedia.org/wiki/Stripe,_Inc.",
|
||||
snippet="Stripe, Inc. is an American financial services company.",
|
||||
),
|
||||
SearchResult(
|
||||
title="Stripe | Financial Infrastructure",
|
||||
url="https://stripe.com",
|
||||
snippet="Stripe powers online and in-person payment processing.",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
url, consulted = await _resolve_official_website(search, "Stripe", None)
|
||||
|
||||
assert url == "https://stripe.com"
|
||||
assert consulted == ["https://stripe.com"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_official_website_falls_back_to_top_result_when_all_are_reference_sites():
|
||||
search = _StubSearchProvider(
|
||||
[
|
||||
SearchResult(
|
||||
title="Acme - Wikipedia",
|
||||
url="https://en.wikipedia.org/wiki/Acme",
|
||||
snippet="An encyclopedia article.",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
url, consulted = await _resolve_official_website(search, "Acme", None)
|
||||
|
||||
assert url == "https://en.wikipedia.org/wiki/Acme"
|
||||
assert consulted == ["https://en.wikipedia.org/wiki/Acme"]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""app.tasks.enrichment.enrich_company: the Celery task end-to-end
|
||||
(mocked HTTP, real DB) - confirms the CompanyEnrichment row persists with
|
||||
the expected status after the task runs, and that a missing company is a
|
||||
clean no-op rather than a crash."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.models.company import Company
|
||||
from app.repositories.company_enrichment_repository import CompanyEnrichmentRepository
|
||||
from app.tasks.enrichment import _enrich_company_async
|
||||
|
||||
|
||||
async def _make_company(db_session) -> Company:
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
name="Acme Corp",
|
||||
slug=f"acme-{uuid.uuid4().hex[:6]}",
|
||||
official_website="https://acme.example.com",
|
||||
)
|
||||
db_session.add(company)
|
||||
await db_session.commit()
|
||||
return company
|
||||
|
||||
|
||||
def _mock_empty_endpoints() -> None:
|
||||
for path in (
|
||||
"company/details",
|
||||
"company/funding",
|
||||
"company/updates",
|
||||
"competitor/listing",
|
||||
"product/listing",
|
||||
"customer/listing",
|
||||
):
|
||||
respx.get(f"https://nubela.co/api/v1/{path}").mock(
|
||||
return_value=httpx.Response(200, json={})
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_company_task_persists_a_complete_result(db_session, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"app.tasks.enrichment.get_settings", lambda: Settings(ninjapear_api_key="test-key")
|
||||
)
|
||||
company = await _make_company(db_session)
|
||||
|
||||
with respx.mock:
|
||||
_mock_empty_endpoints()
|
||||
await _enrich_company_async(str(company.id))
|
||||
|
||||
enrichment = await CompanyEnrichmentRepository(db_session).get_for_company(company.id)
|
||||
assert enrichment is not None
|
||||
assert enrichment.status.value == "complete"
|
||||
assert enrichment.fetched_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_company_task_records_partial_status_on_a_failed_section(
|
||||
db_session, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"app.tasks.enrichment.get_settings", lambda: Settings(ninjapear_api_key="test-key")
|
||||
)
|
||||
company = await _make_company(db_session)
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://nubela.co/api/v1/company/details").mock(
|
||||
return_value=httpx.Response(200, json={})
|
||||
)
|
||||
respx.get("https://nubela.co/api/v1/company/funding").mock(
|
||||
return_value=httpx.Response(500, text="boom")
|
||||
)
|
||||
respx.get("https://nubela.co/api/v1/company/updates").mock(
|
||||
return_value=httpx.Response(200, json={})
|
||||
)
|
||||
respx.get("https://nubela.co/api/v1/competitor/listing").mock(
|
||||
return_value=httpx.Response(200, json={})
|
||||
)
|
||||
respx.get("https://nubela.co/api/v1/product/listing").mock(
|
||||
return_value=httpx.Response(200, json={})
|
||||
)
|
||||
respx.get("https://nubela.co/api/v1/customer/listing").mock(
|
||||
return_value=httpx.Response(200, json={})
|
||||
)
|
||||
await _enrich_company_async(str(company.id))
|
||||
|
||||
enrichment = await CompanyEnrichmentRepository(db_session).get_for_company(company.id)
|
||||
assert enrichment.status.value == "partial"
|
||||
assert "funding" in enrichment.errors
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_company_task_is_a_no_op_for_a_missing_company(db_session, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"app.tasks.enrichment.get_settings", lambda: Settings(ninjapear_api_key="test-key")
|
||||
)
|
||||
# Should return cleanly rather than raising - no company, nothing to do.
|
||||
await _enrich_company_async(str(uuid.uuid4()))
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Data-retention purge task: only SourceDocument rows older than
|
||||
DATA_RETENTION_DAYS are deleted, everything newer is untouched."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.company import Company
|
||||
from app.models.enums import SourceType
|
||||
from app.models.source import Source
|
||||
from app.models.source_document import SourceDocument
|
||||
from app.repositories.source_repository import SourceDocumentRepository
|
||||
from app.tasks.maintenance import _purge_expired_data_async
|
||||
|
||||
|
||||
async def _make_document(
|
||||
db_session, company, source, *, retrieved_date: datetime
|
||||
) -> SourceDocument:
|
||||
return await SourceDocumentRepository(db_session).create(
|
||||
source_id=source.id,
|
||||
company_id=company.id,
|
||||
url=f"https://acme.example/{uuid.uuid4().hex[:8]}",
|
||||
canonical_url=f"https://acme.example/{uuid.uuid4().hex[:8]}",
|
||||
title="Doc",
|
||||
author=None,
|
||||
publication_date=None,
|
||||
retrieved_date=retrieved_date,
|
||||
content_text="Some content",
|
||||
content_hash=uuid.uuid4().hex,
|
||||
metadata_json={},
|
||||
extraction_method="test",
|
||||
trust_score=0.7,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purge_deletes_only_documents_older_than_retention_window(db_session, settings):
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
name="Retention Co",
|
||||
slug=f"retention-co-{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db_session.add(company)
|
||||
await db_session.flush()
|
||||
|
||||
source = Source(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.WEBSITE,
|
||||
name="Site",
|
||||
base_url="https://acme.example",
|
||||
)
|
||||
db_session.add(source)
|
||||
await db_session.flush()
|
||||
|
||||
now = datetime.now(UTC)
|
||||
old_doc = await _make_document(
|
||||
db_session,
|
||||
company,
|
||||
source,
|
||||
retrieved_date=now - timedelta(days=settings.data_retention_days + 30),
|
||||
)
|
||||
recent_doc = await _make_document(
|
||||
db_session, company, source, retrieved_date=now - timedelta(days=1)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
await _purge_expired_data_async()
|
||||
|
||||
remaining_ids = set((await db_session.execute(select(SourceDocument.id))).scalars().all())
|
||||
assert old_doc.id not in remaining_ids
|
||||
assert recent_doc.id in remaining_ids
|
||||
@@ -0,0 +1,126 @@
|
||||
"""run_monitoring's per-source due-ness filter: a SCHEDULED run only
|
||||
collects sources that are actually due (an overdue fast-cadence source
|
||||
alongside a not-yet-due default-cadence one), while a MANUAL "Run now"
|
||||
always collects every active source regardless of individual cadence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.models.company import Company
|
||||
from app.models.enums import MonitoringFrequency, MonitoringRunTrigger, SourceType
|
||||
from app.models.monitor_configuration import MonitorConfiguration
|
||||
from app.models.source import Source
|
||||
from app.repositories.monitoring_run_repository import MonitoringRunRepository
|
||||
from app.tasks.scheduler import _sync_schedules_async
|
||||
|
||||
RSS_FEED = (
|
||||
'<?xml version="1.0"?><rss version="2.0"><channel><title>News</title>'
|
||||
"<item><title>Update</title><link>https://example.com/n</link>"
|
||||
"<description>Something happened.</description></item></channel></rss>"
|
||||
)
|
||||
|
||||
|
||||
def _register_and_login(client) -> dict[str, str]:
|
||||
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
async def _make_company_with_two_sources(db_session):
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
name="Scheduling Co",
|
||||
slug=f"scheduling-co-{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db_session.add(company)
|
||||
db_session.add(
|
||||
MonitorConfiguration(
|
||||
company_id=company.id,
|
||||
frequency_type=MonitoringFrequency.WEEKLY,
|
||||
enabled=True,
|
||||
next_run=datetime.now(UTC) + timedelta(hours=1), # not due
|
||||
)
|
||||
)
|
||||
due_source = Source(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.RSS,
|
||||
name="Daily News",
|
||||
base_url="https://example.com/feed",
|
||||
active=True,
|
||||
frequency_type=MonitoringFrequency.DAILY,
|
||||
next_check=datetime.now(UTC) - timedelta(minutes=5), # overdue
|
||||
)
|
||||
not_due_source = Source(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.RSS,
|
||||
name="Default Cadence Feed",
|
||||
base_url="https://example.com/other-feed",
|
||||
active=True,
|
||||
# frequency_type left None - inherits the company's weekly default,
|
||||
# which per MonitorConfiguration.next_run above isn't due yet.
|
||||
)
|
||||
db_session.add_all([due_source, not_due_source])
|
||||
await db_session.commit()
|
||||
return company
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduled_run_only_collects_the_due_source(db_session):
|
||||
company = await _make_company_with_two_sources(db_session)
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/feed").mock(return_value=httpx.Response(200, text=RSS_FEED))
|
||||
await _sync_schedules_async()
|
||||
|
||||
runs = await MonitoringRunRepository(db_session).list_for_company(company.id)
|
||||
assert len(runs) == 1
|
||||
run = runs[0]
|
||||
assert run.trigger_type == MonitoringRunTrigger.SCHEDULED
|
||||
assert run.sources_attempted == 1
|
||||
assert run.sources_successful == 1
|
||||
|
||||
|
||||
def test_manual_run_collects_every_active_source_regardless_of_cadence(client):
|
||||
headers = _register_and_login(client)
|
||||
company = client.post(
|
||||
"/api/v1/companies",
|
||||
json={"name": f"Manual Sched Co {uuid.uuid4().hex[:6]}", "frequency_type": "weekly"},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
for name, url in [("A", "https://example.com/a"), ("B", "https://example.com/b")]:
|
||||
client.post(
|
||||
f"/api/v1/companies/{company['id']}/sources",
|
||||
json={"source_type": "custom_url", "name": name, "base_url": url},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/a/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/a").mock(
|
||||
return_value=httpx.Response(200, html="<html><body>A</body></html>")
|
||||
)
|
||||
respx.get("https://example.com/b/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/b").mock(
|
||||
return_value=httpx.Response(200, html="<html><body>B</body></html>")
|
||||
)
|
||||
run = client.post(f"/api/v1/companies/{company['id']}/run", headers=headers).json()
|
||||
|
||||
detail = client.get(f"/api/v1/runs/{run['id']}", headers=headers).json()
|
||||
assert detail["sources_attempted"] == 2
|
||||
assert detail["sources_successful"] == 2
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Report generation end-to-end against a real (SQLite) DB: real
|
||||
SourceDocument/DetectedChange rows in, a persisted Report (JSON + Markdown)
|
||||
out, via the mock LLM provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.analysis.llm.mock import MockLLMProvider
|
||||
from app.models.company import Company
|
||||
from app.models.detected_change import DetectedChange
|
||||
from app.models.enums import ChangeType, MonitoringRunTrigger, ReportType, SeverityLevel, SourceType
|
||||
from app.models.monitoring_run import MonitoringRun
|
||||
from app.models.report import Report
|
||||
from app.models.snapshot import Snapshot
|
||||
from app.models.source import Source
|
||||
from app.models.source_document import SourceDocument
|
||||
from app.repositories.company_repository import CompanyRepository
|
||||
from app.services.report_service import generate_and_persist_report
|
||||
|
||||
|
||||
async def _make_company_with_evidence(db_session) -> Company:
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
name="Acme Mobility Systems",
|
||||
slug=f"acme-mobility-{uuid.uuid4().hex[:6]}",
|
||||
monitoring_focus="EV manufacturing expansion and battery technology",
|
||||
)
|
||||
db_session.add(company)
|
||||
await db_session.flush()
|
||||
|
||||
source = Source(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.JOB_POSTING,
|
||||
name="Careers",
|
||||
base_url="https://acme.example/careers",
|
||||
)
|
||||
db_session.add(source)
|
||||
await db_session.flush()
|
||||
|
||||
db_session.add(
|
||||
SourceDocument(
|
||||
source_id=source.id,
|
||||
company_id=company.id,
|
||||
url="https://acme.example/careers/battery-engineer",
|
||||
canonical_url="https://acme.example/careers/battery-engineer",
|
||||
title="Senior Battery Engineer",
|
||||
author=None,
|
||||
publication_date=None,
|
||||
retrieved_date=datetime.now(UTC),
|
||||
content_text="We are hiring a senior battery engineer to lead our new EV platform.",
|
||||
content_hash="hash1",
|
||||
metadata_json={},
|
||||
extraction_method="job_link_heuristic",
|
||||
trust_score=0.7,
|
||||
)
|
||||
)
|
||||
|
||||
run = MonitoringRun(company_id=company.id, trigger_type=MonitoringRunTrigger.MANUAL)
|
||||
db_session.add(run)
|
||||
await db_session.flush()
|
||||
|
||||
snapshot = Snapshot(
|
||||
company_id=company.id,
|
||||
source_id=source.id,
|
||||
snapshot_type=source.source_type.value,
|
||||
hash="hash1",
|
||||
structured_summary={"urls": ["/careers/battery-engineer"]},
|
||||
text_summary="Senior Battery Engineer",
|
||||
monitoring_run_id=run.id,
|
||||
)
|
||||
db_session.add(snapshot)
|
||||
await db_session.flush()
|
||||
|
||||
db_session.add(
|
||||
DetectedChange(
|
||||
company_id=company.id,
|
||||
source_id=source.id,
|
||||
monitoring_run_id=run.id,
|
||||
current_snapshot_id=snapshot.id,
|
||||
change_type=ChangeType.NEW_DOCUMENT,
|
||||
raw_diff={"structured_added": ["/careers/battery-engineer"], "structured_removed": []},
|
||||
significance_score=0.6,
|
||||
confidence_score=0.75,
|
||||
severity=SeverityLevel.HIGH,
|
||||
summary="1 new item detected",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
return await CompanyRepository(db_session).get_for_user(company.id, company.user_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_report_persists_structured_and_markdown(db_session, settings):
|
||||
company = await _make_company_with_evidence(db_session)
|
||||
llm = MockLLMProvider()
|
||||
|
||||
report = await generate_and_persist_report(
|
||||
db_session, settings, llm, company, report_type=ReportType.BASELINE
|
||||
)
|
||||
|
||||
assert report.id is not None
|
||||
assert report.report_type == ReportType.BASELINE
|
||||
assert report.model_provider == "mock"
|
||||
assert "Acme Mobility Systems" in report.executive_summary
|
||||
assert report.structured_report["executive_summary"] == report.executive_summary
|
||||
assert len(report.structured_report["recent_developments"]) == 1
|
||||
assert len(report.structured_report["hiring_signals"]) == 1
|
||||
|
||||
assert "# Competitive Intelligence Report: Acme Mobility Systems" in report.markdown_content
|
||||
assert "## 15. Sources" in report.markdown_content
|
||||
assert "battery-engineer" in report.markdown_content
|
||||
|
||||
persisted = (
|
||||
await db_session.execute(select(Report).where(Report.id == report.id))
|
||||
).scalar_one()
|
||||
assert persisted.company_id == company.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_report_with_no_evidence_is_still_honest(db_session, settings):
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
name="Empty Co",
|
||||
slug=f"empty-co-{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db_session.add(company)
|
||||
await db_session.commit()
|
||||
result = await db_session.execute(
|
||||
select(Company)
|
||||
.where(Company.id == company.id)
|
||||
.options(
|
||||
selectinload(Company.aliases),
|
||||
selectinload(Company.competitors),
|
||||
selectinload(Company.enrichment),
|
||||
)
|
||||
)
|
||||
company = result.scalar_one()
|
||||
|
||||
report = await generate_and_persist_report(
|
||||
db_session, settings, MockLLMProvider(), company, report_type=ReportType.BASELINE
|
||||
)
|
||||
|
||||
assert "0 collected document" in report.executive_summary
|
||||
assert report.structured_report["recent_developments"] == []
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Celery Beat's sync_schedules task: dynamic due-schedule discovery,
|
||||
idempotent skip of companies already mid-run, and delegation to
|
||||
run_monitoring.delay for everything else."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.company import Company
|
||||
from app.models.enums import MonitoringFrequency, MonitoringRunTrigger, SourceType
|
||||
from app.models.monitor_configuration import MonitorConfiguration
|
||||
from app.models.monitoring_run import MonitoringRun
|
||||
from app.models.source import Source
|
||||
from app.repositories.monitoring_run_repository import MonitoringRunRepository
|
||||
from app.tasks.scheduler import _sync_schedules_async
|
||||
|
||||
|
||||
async def _make_due_company(db_session, *, next_run_offset_minutes: int, enabled: bool = True):
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
name="Scheduled Co",
|
||||
slug=f"scheduled-co-{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db_session.add(company)
|
||||
db_session.add(
|
||||
MonitorConfiguration(
|
||||
company_id=company.id,
|
||||
frequency_type=MonitoringFrequency.WEEKLY,
|
||||
enabled=enabled,
|
||||
next_run=datetime.now(UTC) + timedelta(minutes=next_run_offset_minutes),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
return company
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_schedules_enqueues_due_companies(db_session):
|
||||
company = await _make_due_company(db_session, next_run_offset_minutes=-5) # 5 min overdue
|
||||
|
||||
with patch("app.tasks.collection.run_monitoring.delay") as mock_delay:
|
||||
await _sync_schedules_async()
|
||||
|
||||
mock_delay.assert_called_once()
|
||||
|
||||
result = await db_session.execute(
|
||||
select(MonitoringRun).where(MonitoringRun.company_id == company.id)
|
||||
)
|
||||
run = result.scalar_one()
|
||||
assert run.trigger_type == MonitoringRunTrigger.SCHEDULED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_schedules_skips_not_yet_due_companies(db_session):
|
||||
await _make_due_company(db_session, next_run_offset_minutes=60) # due in the future
|
||||
|
||||
with patch("app.tasks.collection.run_monitoring.delay") as mock_delay:
|
||||
await _sync_schedules_async()
|
||||
|
||||
mock_delay.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_schedules_skips_disabled_configs(db_session):
|
||||
await _make_due_company(db_session, next_run_offset_minutes=-5, enabled=False)
|
||||
|
||||
with patch("app.tasks.collection.run_monitoring.delay") as mock_delay:
|
||||
await _sync_schedules_async()
|
||||
|
||||
mock_delay.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_schedules_does_not_double_enqueue_a_company_with_an_active_run(db_session):
|
||||
company = await _make_due_company(db_session, next_run_offset_minutes=-5)
|
||||
|
||||
run_repo = MonitoringRunRepository(db_session)
|
||||
await run_repo.create(company_id=company.id, trigger_type=MonitoringRunTrigger.MANUAL)
|
||||
await db_session.commit()
|
||||
|
||||
with patch("app.tasks.collection.run_monitoring.delay") as mock_delay:
|
||||
await _sync_schedules_async()
|
||||
|
||||
mock_delay.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_schedules_enqueues_via_a_source_override_even_when_company_default_isnt_due(
|
||||
db_session,
|
||||
):
|
||||
"""A source with its own faster cadence (e.g. "check news daily") must
|
||||
be able to trigger a run even while the company's own default schedule
|
||||
(e.g. weekly) isn't due yet - this is the whole point of per-source
|
||||
scheduling."""
|
||||
company = await _make_due_company(db_session, next_run_offset_minutes=60) # not due
|
||||
db_session.add(
|
||||
Source(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.RSS,
|
||||
name="Daily News",
|
||||
active=True,
|
||||
frequency_type=MonitoringFrequency.DAILY,
|
||||
next_check=datetime.now(UTC) - timedelta(minutes=5), # overdue
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
with patch("app.tasks.collection.run_monitoring.delay") as mock_delay:
|
||||
await _sync_schedules_async()
|
||||
|
||||
mock_delay.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_schedules_skips_when_neither_company_default_nor_any_source_is_due(db_session):
|
||||
company = await _make_due_company(db_session, next_run_offset_minutes=60) # not due
|
||||
db_session.add(
|
||||
Source(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.RSS,
|
||||
name="Daily News",
|
||||
active=True,
|
||||
frequency_type=MonitoringFrequency.DAILY,
|
||||
next_check=datetime.now(UTC) + timedelta(hours=12), # not due yet
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
with patch("app.tasks.collection.run_monitoring.delay") as mock_delay:
|
||||
await _sync_schedules_async()
|
||||
|
||||
mock_delay.assert_not_called()
|
||||
@@ -0,0 +1,173 @@
|
||||
"""End-to-end proof that a real monitoring run wires all the way through to
|
||||
a delivered notification: baseline run -> content changes -> second run's
|
||||
DetectedChange crosses the company's (LOW) severity threshold -> Alert
|
||||
created -> dispatched to the registered email destination -> smtplib
|
||||
(mocked) actually gets called. Everything else is the real HTTP API +
|
||||
Celery-eager pipeline, exactly as a user would trigger it from the
|
||||
dashboard; only DNS and the outbound SMTP socket are mocked."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import respx
|
||||
|
||||
|
||||
def _register_and_login(client) -> dict[str, str]:
|
||||
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def _mock_empty_discovery():
|
||||
respx.get("https://api.github.com/search/users").mock(
|
||||
return_value=httpx.Response(200, text=json.dumps({"items": []}))
|
||||
)
|
||||
respx.get("https://www.sec.gov/cgi-bin/browse-edgar").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text='<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>'
|
||||
)
|
||||
)
|
||||
# RSS (Google News) and GOV_CONTRACT (USASpending) are always discovered
|
||||
# unconditionally - see discovery_service.py's _PREVIEWABLE_TYPES - so
|
||||
# their collect() calls need mocking here too.
|
||||
respx.get(url__regex=r"https://news\.google\.com/rss/search.*").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
text=(
|
||||
'<?xml version="1.0"?><rss version="2.0"><channel><title>Google News</title>'
|
||||
"<item><title>Test</title><link>https://example.com/news</link>"
|
||||
"<description>Test news item</description></item></channel></rss>"
|
||||
),
|
||||
)
|
||||
)
|
||||
respx.post("https://api.usaspending.gov/api/v2/search/spending_by_award/").mock(
|
||||
return_value=httpx.Response(200, json={"results": []})
|
||||
)
|
||||
|
||||
|
||||
def test_monitoring_run_detecting_a_change_produces_alert_and_email(client, monkeypatch):
|
||||
sent_emails = []
|
||||
|
||||
class FakeSmtp:
|
||||
def __init__(self, host, port, timeout=10):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
pass
|
||||
|
||||
def login(self, username, password):
|
||||
pass
|
||||
|
||||
def sendmail(self, from_addr, to_addrs, message):
|
||||
sent_emails.append({"to": to_addrs, "message": message})
|
||||
|
||||
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
|
||||
|
||||
headers = _register_and_login(client)
|
||||
|
||||
company = client.post(
|
||||
"/api/v1/companies",
|
||||
json={
|
||||
"name": f"Acme Mobility {uuid.uuid4().hex[:6]}",
|
||||
"frequency_type": "weekly",
|
||||
"severity_threshold": "low",
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
client.post(
|
||||
f"/api/v1/companies/{company['id']}/sources",
|
||||
json={
|
||||
"source_type": "custom_url",
|
||||
"name": "Pricing",
|
||||
"base_url": "https://example.com/pricing",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"minimum_severity": "low",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
baseline_html = (
|
||||
"<html><head><title>Pricing</title></head><body>"
|
||||
"<article><h1>Pricing</h1><p>The base plan is $49/month.</p></article>"
|
||||
"</body></html>"
|
||||
)
|
||||
changed_html = (
|
||||
"<html><head><title>Pricing</title></head><body>"
|
||||
"<article><h1>Pricing</h1><p>The base plan is $99/month.</p></article>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
with respx.mock:
|
||||
_mock_empty_discovery()
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/pricing").mock(
|
||||
return_value=httpx.Response(200, html=baseline_html)
|
||||
)
|
||||
baseline_run = client.post(
|
||||
f"/api/v1/companies/{company['id']}/run", headers=headers
|
||||
).json()
|
||||
|
||||
with respx.mock:
|
||||
_mock_empty_discovery()
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/pricing").mock(
|
||||
return_value=httpx.Response(200, html=changed_html)
|
||||
)
|
||||
second_run = client.post(
|
||||
f"/api/v1/companies/{company['id']}/run", headers=headers
|
||||
).json()
|
||||
|
||||
assert (
|
||||
client.get(f"/api/v1/runs/{baseline_run['id']}", headers=headers).json()["status"]
|
||||
== "successful"
|
||||
)
|
||||
assert (
|
||||
client.get(f"/api/v1/runs/{second_run['id']}", headers=headers).json()["status"]
|
||||
== "successful"
|
||||
)
|
||||
|
||||
alerts = client.get(
|
||||
"/api/v1/alerts", headers=headers, params={"company_id": company["id"]}
|
||||
).json()
|
||||
assert len(alerts) == 1
|
||||
alert = alerts[0]
|
||||
assert alert["severity"] in {"low", "medium", "high", "critical"}
|
||||
|
||||
detail = client.get(f"/api/v1/alerts/{alert['id']}", headers=headers).json()
|
||||
assert len(detail["deliveries"]) == 1
|
||||
assert detail["deliveries"][0]["status"] == "sent"
|
||||
|
||||
# Registration now also sends a verification-code email through this
|
||||
# same SMTP path (no RESEND_API_KEY configured in tests, so
|
||||
# security_email_service falls back to SmtpEmailProvider) - filter to
|
||||
# the alert email specifically rather than assuming it's the only one.
|
||||
alert_emails = [e for e in sent_emails if e["to"] == ["[email protected]"]]
|
||||
assert len(alert_emails) == 1
|
||||
assert alert["title"] in alert_emails[0]["message"]
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Alerts API: ownership isolation, filters, and read/resolve mutations.
|
||||
Alerts are only ever created internally by alert_service (never via a user
|
||||
POST), so tests seed rows directly through db_session against the same
|
||||
user_id the HTTP client is authenticated as (looked up via GET /auth/me)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.alert import Alert
|
||||
from app.models.company import Company
|
||||
from app.models.detected_change import DetectedChange
|
||||
from app.models.enums import ChangeType, MonitoringRunTrigger, SeverityLevel, SourceType
|
||||
from app.models.monitoring_run import MonitoringRun
|
||||
from app.models.snapshot import Snapshot
|
||||
from app.models.source import Source
|
||||
|
||||
|
||||
def _register_and_login(client) -> tuple[dict[str, str], uuid.UUID]:
|
||||
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
user_id = uuid.UUID(client.get("/api/v1/auth/me", headers=headers).json()["id"])
|
||||
return headers, user_id
|
||||
|
||||
|
||||
async def _make_alert(
|
||||
db_session,
|
||||
user_id: uuid.UUID,
|
||||
*,
|
||||
severity: SeverityLevel = SeverityLevel.HIGH,
|
||||
read: bool = False,
|
||||
resolved: bool = False,
|
||||
) -> Alert:
|
||||
company = Company(
|
||||
id=uuid.uuid4(), user_id=user_id, name="Acme Mobility", slug=f"acme-{uuid.uuid4().hex[:6]}"
|
||||
)
|
||||
db_session.add(company)
|
||||
await db_session.flush()
|
||||
|
||||
source = Source(
|
||||
company_id=company.id,
|
||||
source_type=SourceType.WEBSITE,
|
||||
name="Website",
|
||||
base_url="https://acme.example",
|
||||
)
|
||||
db_session.add(source)
|
||||
await db_session.flush()
|
||||
|
||||
run = MonitoringRun(company_id=company.id, trigger_type=MonitoringRunTrigger.MANUAL)
|
||||
db_session.add(run)
|
||||
await db_session.flush()
|
||||
|
||||
snapshot = Snapshot(
|
||||
company_id=company.id,
|
||||
source_id=source.id,
|
||||
snapshot_type=source.source_type.value,
|
||||
hash="hash1",
|
||||
structured_summary={},
|
||||
text_summary="",
|
||||
monitoring_run_id=run.id,
|
||||
)
|
||||
db_session.add(snapshot)
|
||||
await db_session.flush()
|
||||
|
||||
change = DetectedChange(
|
||||
company_id=company.id,
|
||||
source_id=source.id,
|
||||
monitoring_run_id=run.id,
|
||||
current_snapshot_id=snapshot.id,
|
||||
change_type=ChangeType.NEW_DOCUMENT,
|
||||
raw_diff={},
|
||||
significance_score=0.6,
|
||||
confidence_score=0.75,
|
||||
severity=severity,
|
||||
summary="Change detected",
|
||||
)
|
||||
db_session.add(change)
|
||||
await db_session.flush()
|
||||
|
||||
alert = Alert(
|
||||
company_id=company.id,
|
||||
detected_change_id=change.id,
|
||||
user_id=user_id,
|
||||
title="New hire announced",
|
||||
summary="A new VP of Engineering was announced.",
|
||||
why_it_matters="Signals a scaling push.",
|
||||
severity=severity,
|
||||
confidence=0.75,
|
||||
read=read,
|
||||
resolved=resolved,
|
||||
)
|
||||
db_session.add(alert)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(alert)
|
||||
return alert
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_alerts_scoped_to_owner(client, db_session):
|
||||
owner_headers, owner_id = _register_and_login(client)
|
||||
_other_headers, other_id = _register_and_login(client)
|
||||
|
||||
await _make_alert(db_session, owner_id)
|
||||
await _make_alert(db_session, other_id)
|
||||
|
||||
resp = client.get("/api/v1/alerts", headers=owner_headers)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_alerts_filters_by_severity(client, db_session):
|
||||
headers, user_id = _register_and_login(client)
|
||||
await _make_alert(db_session, user_id, severity=SeverityLevel.CRITICAL)
|
||||
await _make_alert(db_session, user_id, severity=SeverityLevel.LOW)
|
||||
|
||||
resp = client.get("/api/v1/alerts", headers=headers, params={"severity": "critical"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["severity"] == "critical"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_alerts_filters_by_read_and_resolved(client, db_session):
|
||||
headers, user_id = _register_and_login(client)
|
||||
await _make_alert(db_session, user_id, read=True, resolved=False)
|
||||
await _make_alert(db_session, user_id, read=False, resolved=False)
|
||||
|
||||
resp = client.get("/api/v1/alerts", headers=headers, params={"read": "false"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["read"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_alert_detail_includes_deliveries(client, db_session):
|
||||
headers, user_id = _register_and_login(client)
|
||||
alert = await _make_alert(db_session, user_id)
|
||||
|
||||
resp = client.get(f"/api/v1/alerts/{alert.id}", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["id"] == str(alert.id)
|
||||
assert body["deliveries"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_alert_not_owned_returns_404(client, db_session):
|
||||
_owner_headers, owner_id = _register_and_login(client)
|
||||
other_headers, _other_id = _register_and_login(client)
|
||||
alert = await _make_alert(db_session, owner_id)
|
||||
|
||||
resp = client.get(f"/api/v1/alerts/{alert.id}", headers=other_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_alert_read(client, db_session):
|
||||
headers, user_id = _register_and_login(client)
|
||||
alert = await _make_alert(db_session, user_id, read=False)
|
||||
|
||||
resp = client.post(f"/api/v1/alerts/{alert.id}/read", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["read"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_alert(client, db_session):
|
||||
headers, user_id = _register_and_login(client)
|
||||
alert = await _make_alert(db_session, user_id, resolved=False)
|
||||
|
||||
resp = client.post(f"/api/v1/alerts/{alert.id}/resolve", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["resolved"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_alert_updates_both_flags(client, db_session):
|
||||
headers, user_id = _register_and_login(client)
|
||||
alert = await _make_alert(db_session, user_id)
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/alerts/{alert.id}", json={"read": True, "resolved": True}, headers=headers
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["read"] is True
|
||||
assert body["resolved"] is True
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Auth flow tests. Runs under AUTH_MODE=jwt (the suite default) except where
|
||||
`local_mode_client` explicitly exercises the local-dev-user path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.main import app
|
||||
from app.models.enums import SecurityEventType
|
||||
from app.models.user import LOCAL_DEV_USER_ID
|
||||
from app.repositories.user_security_event_repository import UserSecurityEventRepository
|
||||
|
||||
|
||||
def _unique_email() -> str:
|
||||
return f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
|
||||
|
||||
def test_register_then_login(client):
|
||||
email = _unique_email()
|
||||
register_resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": email,
|
||||
"password": "correct-horse-1",
|
||||
"display_name": "Test User",
|
||||
},
|
||||
)
|
||||
assert register_resp.status_code == 201
|
||||
body = register_resp.json()
|
||||
assert body["email"] == email
|
||||
assert "password" not in body
|
||||
assert "password_hash" not in body
|
||||
|
||||
login_resp = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
tokens = login_resp.json()
|
||||
assert tokens["access_token"]
|
||||
assert tokens["refresh_token"]
|
||||
assert tokens["token_type"] == "bearer"
|
||||
|
||||
|
||||
def test_register_duplicate_email_conflicts(client):
|
||||
email = _unique_email()
|
||||
payload = {"email": email, "password": "correct-horse-1", "display_name": "Test User"}
|
||||
first = client.post("/api/v1/auth/register", json=payload)
|
||||
assert first.status_code == 201
|
||||
|
||||
second = client.post("/api/v1/auth/register", json=payload)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
def test_register_rejects_weak_password(client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": _unique_email(), "password": "allletters", "display_name": "Test User"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_login_wrong_password_rejected(client):
|
||||
email = _unique_email()
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
resp = client.post("/api/v1/auth/login", json={"email": email, "password": "wrong-password-1"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_me_requires_bearer_token(client):
|
||||
resp = client.get("/api/v1/auth/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_me_returns_current_user(client):
|
||||
email = _unique_email()
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
login = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
|
||||
resp = client.get(
|
||||
"/api/v1/auth/me", headers={"Authorization": f"Bearer {login['access_token']}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["email"] == email
|
||||
assert body["auth_mode"] == "jwt"
|
||||
|
||||
|
||||
def test_refresh_rotates_and_invalidates_old_token(client):
|
||||
email = _unique_email()
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
login = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
|
||||
refresh_resp = client.post(
|
||||
"/api/v1/auth/refresh", json={"refresh_token": login["refresh_token"]}
|
||||
)
|
||||
assert refresh_resp.status_code == 200
|
||||
new_tokens = refresh_resp.json()
|
||||
assert new_tokens["refresh_token"] != login["refresh_token"]
|
||||
|
||||
# The old refresh token was rotated out and must not be reusable.
|
||||
reuse_resp = client.post("/api/v1/auth/refresh", json={"refresh_token": login["refresh_token"]})
|
||||
assert reuse_resp.status_code == 401
|
||||
|
||||
|
||||
def test_logout_revokes_refresh_token(client):
|
||||
email = _unique_email()
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
login = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
|
||||
logout_resp = client.post("/api/v1/auth/logout", json={"refresh_token": login["refresh_token"]})
|
||||
assert logout_resp.status_code == 204
|
||||
|
||||
reuse_resp = client.post("/api/v1/auth/refresh", json={"refresh_token": login["refresh_token"]})
|
||||
assert reuse_resp.status_code == 401
|
||||
|
||||
|
||||
def test_local_mode_me_returns_fixed_dev_user_without_token(local_mode_client):
|
||||
resp = local_mode_client.get("/api/v1/auth/me")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["email"] == "[email protected]"
|
||||
assert body["auth_mode"] == "local"
|
||||
|
||||
|
||||
def test_register_login_available_even_in_local_mode(local_mode_client):
|
||||
"""Registering/logging in a real account must always be possible,
|
||||
regardless of AUTH_MODE - the loopback convenience only affects whether
|
||||
a request can skip auth entirely, not whether real accounts exist."""
|
||||
email = _unique_email()
|
||||
register_resp = local_mode_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "X"},
|
||||
)
|
||||
assert register_resp.status_code == 201
|
||||
|
||||
login_resp = local_mode_client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
assert login_resp.json()["access_token"]
|
||||
|
||||
|
||||
def test_security_events_requires_auth(client):
|
||||
resp = client.get("/api/v1/auth/security-events")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def _login_success_count(events: list[dict]) -> int:
|
||||
return sum(1 for e in events if e["event_type"] == "login_success")
|
||||
|
||||
|
||||
def test_local_dev_sign_in_is_logged_to_account_activity(local_mode_client):
|
||||
"""The local-dev bypass has no real login step - hitting any
|
||||
authenticated endpoint (here /auth/me, the same check the frontend
|
||||
performs on app load) must still show up as a sign-in."""
|
||||
local_mode_client.get("/api/v1/auth/me")
|
||||
events = local_mode_client.get("/api/v1/auth/security-events").json()
|
||||
assert any(e["event_type"] == "login_success" for e in events)
|
||||
|
||||
|
||||
def test_local_dev_sign_in_is_not_logged_twice_within_cooldown(local_mode_client):
|
||||
"""Every authenticated request runs get_or_create_local_user - without
|
||||
a cooldown, browsing the app at all would flood Account activity with
|
||||
one login_success per request."""
|
||||
local_mode_client.get("/api/v1/auth/me")
|
||||
before = _login_success_count(local_mode_client.get("/api/v1/auth/security-events").json())
|
||||
|
||||
local_mode_client.get("/api/v1/auth/me")
|
||||
after = _login_success_count(local_mode_client.get("/api/v1/auth/security-events").json())
|
||||
|
||||
assert after == before
|
||||
|
||||
|
||||
async def test_local_dev_sign_in_logs_again_after_cooldown_expires(local_mode_client, db_session):
|
||||
local_mode_client.get("/api/v1/auth/me")
|
||||
before = _login_success_count(local_mode_client.get("/api/v1/auth/security-events").json())
|
||||
|
||||
event_repo = UserSecurityEventRepository(db_session)
|
||||
last_login = await event_repo.most_recent_of_type(
|
||||
LOCAL_DEV_USER_ID, SecurityEventType.LOGIN_SUCCESS
|
||||
)
|
||||
assert last_login is not None
|
||||
last_login.created_at = datetime.now(UTC) - timedelta(minutes=31)
|
||||
await db_session.commit()
|
||||
|
||||
local_mode_client.get("/api/v1/auth/me")
|
||||
after = _login_success_count(local_mode_client.get("/api/v1/auth/security-events").json())
|
||||
|
||||
assert after == before + 1
|
||||
|
||||
|
||||
def test_security_events_returns_own_login_events_only(client):
|
||||
email_a = _unique_email()
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email_a, "password": "correct-horse-1", "display_name": "A"},
|
||||
)
|
||||
login_a = client.post(
|
||||
"/api/v1/auth/login", json={"email": email_a, "password": "correct-horse-1"}
|
||||
).json()
|
||||
|
||||
email_b = _unique_email()
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email_b, "password": "correct-horse-1", "display_name": "B"},
|
||||
)
|
||||
client.post("/api/v1/auth/login", json={"email": email_b, "password": "correct-horse-1"})
|
||||
|
||||
resp = client.get(
|
||||
"/api/v1/auth/security-events",
|
||||
headers={"Authorization": f"Bearer {login_a['access_token']}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
events = resp.json()
|
||||
event_types = {e["event_type"] for e in events}
|
||||
# registration writes email_verification_sent, login writes login_success -
|
||||
# both belong to user A only, never user B's events.
|
||||
assert event_types == {"email_verification_sent", "login_success"}
|
||||
|
||||
|
||||
def test_local_mode_setting_alone_does_not_bypass_auth_for_non_loopback_callers():
|
||||
"""AUTH_MODE=local is not a blanket switch - a request that isn't
|
||||
actually from loopback (e.g. a LAN/WAN caller, or here Starlette's
|
||||
TestClient default fake peer) still needs a real bearer token."""
|
||||
settings = get_settings().model_copy(update={"auth_mode": "local"})
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
try:
|
||||
with TestClient(app) as non_loopback_client: # default peer: ("testclient", 50000)
|
||||
resp = non_loopback_client.get("/api/v1/auth/me")
|
||||
assert resp.status_code == 401
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_settings, None)
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Company CRUD, monitor configuration, and ownership isolation tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
def _register_and_login(client) -> dict[str, str]:
|
||||
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def _create_company(client, headers, **overrides):
|
||||
payload = {
|
||||
"name": "Acme Mobility Systems",
|
||||
"official_website": "acme-mobility.example.com",
|
||||
"monitoring_focus": "EV manufacturing expansion",
|
||||
"competitor_names": ["Rival Motors"],
|
||||
"alias_names": ["Acme"],
|
||||
"frequency_type": "weekly",
|
||||
**overrides,
|
||||
}
|
||||
return client.post("/api/v1/companies", json=payload, headers=headers)
|
||||
|
||||
|
||||
def test_create_company_normalizes_website_and_sets_defaults(client):
|
||||
headers = _register_and_login(client)
|
||||
resp = _create_company(client, headers)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["official_website"] == "https://acme-mobility.example.com"
|
||||
assert body["slug"] == "acme-mobility-systems"
|
||||
assert body["status"] == "active"
|
||||
assert body["aliases"] == ["Acme"]
|
||||
assert body["competitors"] == ["Rival Motors"]
|
||||
assert body["monitor_configuration"]["frequency_type"] == "weekly"
|
||||
assert body["monitor_configuration"]["enabled"] is True
|
||||
assert body["monitor_configuration"]["next_run"] is not None
|
||||
|
||||
|
||||
def test_duplicate_company_names_get_distinct_slugs(client):
|
||||
headers = _register_and_login(client)
|
||||
first = _create_company(client, headers).json()
|
||||
second = _create_company(client, headers).json()
|
||||
assert first["slug"] != second["slug"]
|
||||
|
||||
|
||||
def test_duplicate_company_names_get_a_unique_display_name(client):
|
||||
headers = _register_and_login(client)
|
||||
first = _create_company(client, headers).json()
|
||||
second = _create_company(client, headers).json()
|
||||
third = _create_company(client, headers).json()
|
||||
|
||||
assert first["name"] == "Acme Mobility Systems"
|
||||
assert second["name"] == "Acme Mobility Systems (2)"
|
||||
assert third["name"] == "Acme Mobility Systems (3)"
|
||||
|
||||
|
||||
def test_company_name_uniqueness_is_case_insensitive(client):
|
||||
headers = _register_and_login(client)
|
||||
first = _create_company(client, headers, name="Stripe").json()
|
||||
second = _create_company(client, headers, name="STRIPE").json()
|
||||
|
||||
assert first["name"] == "Stripe"
|
||||
assert second["name"] == "STRIPE (2)"
|
||||
|
||||
|
||||
def test_company_name_uniqueness_is_scoped_per_user(client):
|
||||
owner_headers = _register_and_login(client)
|
||||
other_headers = _register_and_login(client)
|
||||
owner_company = _create_company(client, owner_headers).json()
|
||||
other_company = _create_company(client, other_headers).json()
|
||||
|
||||
# Two different users can each have a company with the exact same name -
|
||||
# uniqueness is per-user, not global.
|
||||
assert owner_company["name"] == other_company["name"] == "Acme Mobility Systems"
|
||||
|
||||
|
||||
def test_company_list_and_detail_are_scoped_to_owner(client):
|
||||
owner_headers = _register_and_login(client)
|
||||
other_headers = _register_and_login(client)
|
||||
|
||||
created = _create_company(client, owner_headers).json()
|
||||
|
||||
owner_list = client.get("/api/v1/companies", headers=owner_headers).json()
|
||||
assert any(c["id"] == created["id"] for c in owner_list)
|
||||
|
||||
other_list = client.get("/api/v1/companies", headers=other_headers).json()
|
||||
assert all(c["id"] != created["id"] for c in other_list)
|
||||
|
||||
other_detail = client.get(f"/api/v1/companies/{created['id']}", headers=other_headers)
|
||||
assert other_detail.status_code == 404
|
||||
|
||||
owner_detail = client.get(f"/api/v1/companies/{created['id']}", headers=owner_headers)
|
||||
assert owner_detail.status_code == 200
|
||||
|
||||
|
||||
def test_pause_and_resume_company_toggles_monitor_enabled(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers).json()
|
||||
|
||||
paused = client.post(f"/api/v1/companies/{company['id']}/pause", headers=headers).json()
|
||||
assert paused["status"] == "paused"
|
||||
assert paused["monitor_configuration"]["enabled"] is False
|
||||
|
||||
resumed = client.post(f"/api/v1/companies/{company['id']}/resume", headers=headers).json()
|
||||
assert resumed["status"] == "active"
|
||||
assert resumed["monitor_configuration"]["enabled"] is True
|
||||
|
||||
|
||||
def test_update_company_replaces_aliases_and_competitors(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers).json()
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/companies/{company['id']}",
|
||||
json={"alias_names": ["New Alias"], "competitor_names": []},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["aliases"] == ["New Alias"]
|
||||
assert body["competitors"] == []
|
||||
|
||||
|
||||
def test_delete_company_removes_it(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers).json()
|
||||
|
||||
resp = client.delete(f"/api/v1/companies/{company['id']}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
resp = client.get(f"/api/v1/companies/{company['id']}", headers=headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_monitor_configuration_get_and_patch(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers).json()
|
||||
|
||||
resp = client.get(f"/api/v1/companies/{company['id']}/monitor", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["frequency_type"] == "weekly"
|
||||
|
||||
patch_resp = client.patch(
|
||||
f"/api/v1/companies/{company['id']}/monitor",
|
||||
json={"frequency_type": "daily"},
|
||||
headers=headers,
|
||||
)
|
||||
assert patch_resp.status_code == 200
|
||||
assert patch_resp.json()["frequency_type"] == "daily"
|
||||
|
||||
|
||||
def test_custom_schedule_below_minimum_interval_is_rejected(client, settings):
|
||||
headers = _register_and_login(client)
|
||||
resp = _create_company(
|
||||
client,
|
||||
headers,
|
||||
name="Too Frequent Co",
|
||||
frequency_type="custom",
|
||||
interval_minutes=settings.minimum_monitoring_interval_minutes - 1,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_custom_schedule_with_valid_interval_is_accepted(client, settings):
|
||||
headers = _register_and_login(client)
|
||||
resp = _create_company(
|
||||
client,
|
||||
headers,
|
||||
name="Custom Interval Co",
|
||||
frequency_type="custom",
|
||||
interval_minutes=settings.minimum_monitoring_interval_minutes + 30,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["monitor_configuration"]["interval_minutes"] == (
|
||||
settings.minimum_monitoring_interval_minutes + 30
|
||||
)
|
||||
|
||||
|
||||
def test_custom_schedule_requires_interval_or_cron(client):
|
||||
headers = _register_and_login(client)
|
||||
resp = _create_company(client, headers, name="No Schedule Co", frequency_type="custom")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_company_creation_requires_authentication(client):
|
||||
resp = client.post("/api/v1/companies", json={"name": "No Auth Co"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_max_companies_per_user_enforced(client, settings, monkeypatch):
|
||||
headers = _register_and_login(client)
|
||||
from app.core import config as config_module
|
||||
|
||||
limited_settings = settings.model_copy(update={"max_companies_per_user": 1})
|
||||
from app.main import app
|
||||
|
||||
app.dependency_overrides[config_module.get_settings] = lambda: limited_settings
|
||||
try:
|
||||
first = _create_company(client, headers, name="Company One")
|
||||
assert first.status_code == 201
|
||||
second = _create_company(client, headers, name="Company Two")
|
||||
assert second.status_code == 409
|
||||
finally:
|
||||
app.dependency_overrides.pop(config_module.get_settings, None)
|
||||
|
||||
|
||||
def test_create_company_enqueues_enrichment_when_a_key_is_configured(client, settings):
|
||||
"""The real regression guard is every OTHER test in this suite: none of
|
||||
them configure NINJAPEAR_API_KEY, so the whole rest of the suite proves
|
||||
the enqueue is skipped by default (see the "without a key" test below
|
||||
for the direct assertion)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.core import config as config_module
|
||||
from app.main import app
|
||||
|
||||
key_settings = settings.model_copy(update={"ninjapear_api_key": "test-key"})
|
||||
app.dependency_overrides[config_module.get_settings] = lambda: key_settings
|
||||
try:
|
||||
headers = _register_and_login(client)
|
||||
with patch("app.tasks.enrichment.enrich_company.delay") as mock_delay:
|
||||
resp = _create_company(client, headers, name="Enriched Co")
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
mock_delay.assert_called_once_with(body["id"])
|
||||
assert body["enrichment"] == {
|
||||
"status": "pending",
|
||||
"data": {},
|
||||
"errors": {},
|
||||
"credits_spent": None,
|
||||
"fetched_at": None,
|
||||
}
|
||||
finally:
|
||||
app.dependency_overrides.pop(config_module.get_settings, None)
|
||||
|
||||
|
||||
def test_create_company_does_not_enqueue_enrichment_without_a_key(client):
|
||||
from unittest.mock import patch
|
||||
|
||||
headers = _register_and_login(client)
|
||||
with patch("app.tasks.enrichment.enrich_company.delay") as mock_delay:
|
||||
resp = _create_company(client, headers, name="Plain Co")
|
||||
assert resp.status_code == 201
|
||||
mock_delay.assert_not_called()
|
||||
assert resp.json()["enrichment"] is None
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Correlation-ID middleware: every response echoes an X-Request-ID, reusing
|
||||
an inbound one from a gateway/client if present rather than always minting a
|
||||
fresh one - see app/main.py::correlation_id_middleware."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_response_includes_a_generated_request_id(client):
|
||||
resp = client.get("/health")
|
||||
assert "X-Request-ID" in resp.headers
|
||||
assert len(resp.headers["X-Request-ID"]) > 0
|
||||
|
||||
|
||||
def test_response_reuses_inbound_request_id(client):
|
||||
resp = client.get("/health", headers={"X-Request-ID": "test-correlation-abc123"})
|
||||
assert resp.headers["X-Request-ID"] == "test-correlation-abc123"
|
||||
|
||||
|
||||
def test_each_request_gets_a_distinct_generated_id(client):
|
||||
first = client.get("/health").headers["X-Request-ID"]
|
||||
second = client.get("/health").headers["X-Request-ID"]
|
||||
assert first != second
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
def _register_and_login(client) -> dict[str, str]:
|
||||
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def test_dashboard_analytics_returns_zero_filled_buckets_for_a_new_user(client):
|
||||
headers = _register_and_login(client)
|
||||
|
||||
resp = client.get("/api/v1/dashboard/analytics", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert set(body["changes_by_type"].keys()) == {
|
||||
"new_document",
|
||||
"removed_document",
|
||||
"content_modified",
|
||||
"price_change",
|
||||
"leadership_change",
|
||||
"filing_new",
|
||||
}
|
||||
assert all(v == 0 for v in body["changes_by_type"].values())
|
||||
assert body["recent_signals"] == []
|
||||
|
||||
|
||||
def test_dashboard_analytics_requires_auth(client):
|
||||
resp = client.get("/api/v1/dashboard/analytics")
|
||||
assert resp.status_code in (401, 403)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""POST /companies/discover: persists nothing, returns a proposed profile,
|
||||
rate-limited tightly since it costs a real search + LLM call."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import respx
|
||||
|
||||
from app.core.rate_limit import limiter
|
||||
|
||||
|
||||
def _register_and_login(client) -> dict[str, str]:
|
||||
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def _mock_empty_github_sec():
|
||||
respx.get("https://api.github.com/search/users").mock(
|
||||
return_value=httpx.Response(200, text=json.dumps({"items": []}))
|
||||
)
|
||||
respx.get("https://www.sec.gov/cgi-bin/browse-edgar").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text='<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_discover_returns_a_profile_without_persisting_a_company(client):
|
||||
headers = _register_and_login(client)
|
||||
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
with respx.mock:
|
||||
_mock_empty_github_sec()
|
||||
respx.get("https://acmewidgets.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://acmewidgets.com").mock(
|
||||
return_value=httpx.Response(200, html="<html><body>Acme Widgets</body></html>")
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/v1/companies/discover",
|
||||
json={"name": "Acme Widgets"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["name"] == "Acme Widgets"
|
||||
assert body["official_website"] == "https://acmewidgets.com"
|
||||
assert "potential_sources" in body
|
||||
assert "sources_consulted" in body
|
||||
|
||||
companies = client.get("/api/v1/companies", headers=headers).json()
|
||||
assert companies == []
|
||||
|
||||
|
||||
def test_discover_requires_auth(client):
|
||||
resp = client.post("/api/v1/companies/discover", json={"name": "Acme"})
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_discover_enforces_rate_limit(client):
|
||||
headers = _register_and_login(client)
|
||||
|
||||
limiter.enabled = True
|
||||
try:
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
with respx.mock:
|
||||
_mock_empty_github_sec()
|
||||
respx.route(host="acme0.com").mock(return_value=httpx.Response(404))
|
||||
respx.route(host="acme1.com").mock(return_value=httpx.Response(404))
|
||||
respx.route(host="acme2.com").mock(return_value=httpx.Response(404))
|
||||
respx.route(host="acme3.com").mock(return_value=httpx.Response(404))
|
||||
respx.route(host="acme4.com").mock(return_value=httpx.Response(404))
|
||||
respx.route(host="acme5.com").mock(return_value=httpx.Response(404))
|
||||
statuses = [
|
||||
client.post(
|
||||
"/api/v1/companies/discover",
|
||||
json={"name": f"Acme{i}"},
|
||||
headers=headers,
|
||||
).status_code
|
||||
for i in range(6)
|
||||
]
|
||||
assert 429 in statuses, f"Expected a 429 among {statuses} after 6 rapid discover calls"
|
||||
finally:
|
||||
limiter.enabled = False
|
||||
@@ -0,0 +1,170 @@
|
||||
"""enrichment_service.enrich_company: per-section independent failure
|
||||
handling, the leadership-lookup cap, and overall status derivation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.enrichment.base import (
|
||||
CompanyDetails,
|
||||
CompanyFunding,
|
||||
LeadershipMember,
|
||||
)
|
||||
from app.models.company import Company
|
||||
from app.models.enums import EnrichmentStatus
|
||||
from app.repositories.company_enrichment_repository import CompanyEnrichmentRepository
|
||||
from app.services.enrichment_service import enrich_company
|
||||
|
||||
|
||||
class _FakeProvider:
|
||||
provider_name = "fake"
|
||||
|
||||
def __init__(
|
||||
self, *, leadership: list[LeadershipMember] | None = None, fail: set[str] | None = None
|
||||
):
|
||||
self._leadership = leadership or []
|
||||
self._fail = fail or set()
|
||||
|
||||
async def get_company_details(self, name, website):
|
||||
if "details" in self._fail:
|
||||
raise RuntimeError("details boom")
|
||||
return CompanyDetails(description="A company.", leadership_team=self._leadership)
|
||||
|
||||
async def get_funding(self, name, website):
|
||||
if "funding" in self._fail:
|
||||
raise RuntimeError("funding boom")
|
||||
return CompanyFunding(total_raised="$1M")
|
||||
|
||||
async def get_updates(self, name, website):
|
||||
if "updates" in self._fail:
|
||||
raise RuntimeError("updates boom")
|
||||
return []
|
||||
|
||||
async def get_competitors(self, name, website):
|
||||
if "competitors" in self._fail:
|
||||
raise RuntimeError("competitors boom")
|
||||
return []
|
||||
|
||||
async def get_products(self, name, website):
|
||||
if "products" in self._fail:
|
||||
raise RuntimeError("products boom")
|
||||
return []
|
||||
|
||||
async def get_customers(self, name, website):
|
||||
if "customers" in self._fail:
|
||||
raise RuntimeError("customers boom")
|
||||
return []
|
||||
|
||||
async def get_work_email(self, person_name, company_website):
|
||||
if "work_email" in self._fail:
|
||||
raise RuntimeError("email boom")
|
||||
return f"{person_name.split()[0].lower()}@example.com"
|
||||
|
||||
async def get_person_profile(self, person_name, company_website):
|
||||
if "person_profile" in self._fail:
|
||||
raise RuntimeError("profile boom")
|
||||
return f"https://example.com/{person_name}", "A bio."
|
||||
|
||||
|
||||
async def _make_company(db_session, *, website: str | None = "https://acme.example.com") -> Company:
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
name="Acme Corp",
|
||||
slug=f"acme-{uuid.uuid4().hex[:6]}",
|
||||
official_website=website,
|
||||
)
|
||||
db_session.add(company)
|
||||
await db_session.commit()
|
||||
return company
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_sections_succeeding_yields_complete_status(db_session, settings):
|
||||
company = await _make_company(db_session)
|
||||
provider = _FakeProvider()
|
||||
|
||||
enrichment = await enrich_company(db_session, settings, provider, company)
|
||||
|
||||
assert enrichment.status == EnrichmentStatus.COMPLETE
|
||||
assert enrichment.errors == {}
|
||||
assert enrichment.data["funding"]["total_raised"] == "$1M"
|
||||
assert enrichment.credits_spent is not None and enrichment.credits_spent > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_failed_section_yields_partial_without_losing_the_rest(db_session, settings):
|
||||
company = await _make_company(db_session)
|
||||
provider = _FakeProvider(fail={"funding"})
|
||||
|
||||
enrichment = await enrich_company(db_session, settings, provider, company)
|
||||
|
||||
assert enrichment.status == EnrichmentStatus.PARTIAL
|
||||
assert "funding" in enrichment.errors
|
||||
assert "funding" not in enrichment.data
|
||||
assert enrichment.data["description"] == "A company." # the other sections still ran
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_section_failing_yields_failed_status(db_session, settings):
|
||||
company = await _make_company(db_session)
|
||||
provider = _FakeProvider(
|
||||
fail={"details", "funding", "updates", "competitors", "products", "customers"}
|
||||
)
|
||||
|
||||
enrichment = await enrich_company(db_session, settings, provider, company)
|
||||
|
||||
assert enrichment.status == EnrichmentStatus.FAILED
|
||||
assert enrichment.data.get("leadership_team") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leadership_lookups_are_capped(db_session, settings):
|
||||
company = await _make_company(db_session)
|
||||
leadership = [LeadershipMember(name=f"Person {i}") for i in range(8)]
|
||||
provider = _FakeProvider(leadership=leadership)
|
||||
capped_settings = settings.model_copy(update={"ninjapear_max_leadership_lookups": 3})
|
||||
|
||||
enrichment = await enrich_company(db_session, capped_settings, provider, company)
|
||||
|
||||
team = enrichment.data["leadership_team"]
|
||||
assert len(team) == 8 # every discovered leader is kept...
|
||||
with_email = [m for m in team if m.get("work_email")]
|
||||
assert len(with_email) == 3 # ...but only the first 3 get person-level lookups
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_website_fails_immediately_without_calling_the_provider(db_session, settings):
|
||||
"""NinjaPear identifies a company by website only - every call would
|
||||
fail identically, so this must short-circuit to FAILED before spending
|
||||
any credits, rather than attempting (and paying for) doomed calls."""
|
||||
company = await _make_company(db_session, website=None)
|
||||
|
||||
class _ExplodingProvider:
|
||||
provider_name = "exploding"
|
||||
|
||||
async def get_company_details(self, *a, **k):
|
||||
raise AssertionError("must not be called without a website")
|
||||
|
||||
enrichment = await enrich_company(db_session, settings, _ExplodingProvider(), company)
|
||||
|
||||
assert enrichment.status == EnrichmentStatus.FAILED
|
||||
assert enrichment.credits_spent == 0
|
||||
assert "details" in enrichment.errors
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_updates_the_same_row_on_a_second_call(db_session, settings):
|
||||
company = await _make_company(db_session)
|
||||
|
||||
first = await enrich_company(db_session, settings, _FakeProvider(), company)
|
||||
second = await enrich_company(db_session, settings, _FakeProvider(fail={"funding"}), company)
|
||||
|
||||
assert first.id == second.id
|
||||
assert second.status == EnrichmentStatus.PARTIAL
|
||||
|
||||
stored = await CompanyEnrichmentRepository(db_session).get_for_company(company.id)
|
||||
assert stored.id == first.id
|
||||
assert stored.status == EnrichmentStatus.PARTIAL
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.collectors.extraction import (
|
||||
canonicalize_url,
|
||||
compute_content_hash,
|
||||
extract_readable_text,
|
||||
extract_title,
|
||||
normalize_whitespace,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_whitespace_collapses_blank_lines_and_trims():
|
||||
raw = " Title \n\n\n\nBody line one \n \nBody line two "
|
||||
normalized = normalize_whitespace(raw)
|
||||
assert normalized == "Title\n\nBody line one\n\nBody line two"
|
||||
|
||||
|
||||
def test_compute_content_hash_is_stable_and_sensitive_to_change():
|
||||
a = compute_content_hash("hello world")
|
||||
b = compute_content_hash("hello world")
|
||||
c = compute_content_hash("hello world!")
|
||||
assert a == b
|
||||
assert a != c
|
||||
|
||||
|
||||
def test_canonicalize_url_strips_tracking_params_and_trailing_slash():
|
||||
url = "HTTPS://Example.com/About/?utm_source=x&ref=y"
|
||||
assert canonicalize_url(url) == "https://example.com/About?ref=y"
|
||||
|
||||
|
||||
def test_canonicalize_url_normalizes_root_path():
|
||||
assert canonicalize_url("https://example.com") == "https://example.com/"
|
||||
|
||||
|
||||
def test_extract_readable_text_prefers_trafilatura_for_article_html():
|
||||
html = """
|
||||
<html><body>
|
||||
<nav>Home | About | Contact</nav>
|
||||
<article>
|
||||
<h1>Acme launches new product</h1>
|
||||
<p>Acme Corp announced a new electric vehicle platform today, expanding its lineup.</p>
|
||||
<p>The company said manufacturing will begin next quarter at its main facility.</p>
|
||||
</article>
|
||||
<footer>Copyright 2026</footer>
|
||||
</body></html>
|
||||
"""
|
||||
text, method = extract_readable_text(html, "https://example.com/news/1")
|
||||
assert "Acme launches new product" in text
|
||||
assert "Copyright 2026" not in text
|
||||
assert method in ("trafilatura", "beautifulsoup_fallback")
|
||||
|
||||
|
||||
def test_extract_readable_text_falls_back_when_trafilatura_finds_nothing():
|
||||
html = "<html><body></body></html>"
|
||||
text, method = extract_readable_text(html, "https://example.com/thin")
|
||||
assert method == "beautifulsoup_fallback"
|
||||
assert text == ""
|
||||
|
||||
|
||||
def test_extract_title_prefers_title_tag():
|
||||
html = "<html><head><title>Acme — About</title></head><body><h1>About</h1></body></html>"
|
||||
assert extract_title(html) == "Acme — About"
|
||||
|
||||
|
||||
def test_extract_title_falls_back_to_h1():
|
||||
html = "<html><head></head><body><h1>Fallback Heading</h1></body></html>"
|
||||
assert extract_title(html) == "Fallback Heading"
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.change_detection.extractors import extract_prices, mentions_leadership_title
|
||||
|
||||
|
||||
def test_extract_prices_finds_dollar_amounts():
|
||||
text = "The Pro plan is $49/month and the Enterprise plan is $199.99/month."
|
||||
prices = extract_prices(text)
|
||||
assert "$49/month" in prices
|
||||
assert "$199.99/month" in prices
|
||||
|
||||
|
||||
def test_extract_prices_empty_when_no_prices():
|
||||
assert extract_prices("No pricing information on this page.") == set()
|
||||
|
||||
|
||||
def test_mentions_leadership_title_detects_ceo():
|
||||
assert mentions_leadership_title("Jane Smith has been appointed as the new CEO.") is True
|
||||
|
||||
|
||||
def test_mentions_leadership_title_false_when_absent():
|
||||
assert mentions_leadership_title("We shipped a new feature this week.") is False
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Basic liveness/readiness smoke tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_root_health(client):
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_v1_health(client):
|
||||
resp = client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "ok"
|
||||
assert "app_name" in body
|
||||
|
||||
|
||||
def test_system_status_reports_mock_providers(client):
|
||||
resp = client.get("/api/v1/system/status")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["llm_provider"] == "mock"
|
||||
assert body["search_provider"] == "mock"
|
||||
@@ -0,0 +1,245 @@
|
||||
"""ip_throttle_service: the escalation engine backing resend-verification,
|
||||
resend-password-reset, and failed-login throttling. Every stage is walked
|
||||
by monkeypatching `_now()` forward - no real waiting, no manual
|
||||
brute-forcing, per the explicit "test it smarter" requirement this feature
|
||||
was built under."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.base import ensure_aware_utc
|
||||
from app.models.enums import ThrottleAction
|
||||
from app.repositories.ip_throttle_repository import IpThrottleRepository
|
||||
from app.services import ip_throttle_service
|
||||
from app.services.ip_throttle_service import (
|
||||
LOGIN_BACKOFF_SECONDS,
|
||||
RESEND_BACKOFF_SECONDS,
|
||||
TIMEOUT_LADDER_SECONDS,
|
||||
peek_throttle,
|
||||
record_attempt,
|
||||
reset_on_success,
|
||||
)
|
||||
|
||||
|
||||
def _unique_ip() -> str:
|
||||
return f"10.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}"
|
||||
|
||||
|
||||
class _Clock:
|
||||
"""A monkeypatchable fake clock - advances only when told to, so tests
|
||||
can jump straight past a 5-hour timeout without real time passing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.now = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
def __call__(self) -> datetime:
|
||||
return self.now
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.now += timedelta(seconds=seconds)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def clock(monkeypatch):
|
||||
fake_clock = _Clock()
|
||||
monkeypatch.setattr(ip_throttle_service, "_now", fake_clock)
|
||||
return fake_clock
|
||||
|
||||
|
||||
async def _exhaust_stages_into_timeout(db_session, clock, ip: str, action: ThrottleAction) -> None:
|
||||
"""Drives one full stage array to exhaustion (advancing the clock past
|
||||
each required wait, exactly as a real repeated-offender caller would),
|
||||
landing the state in a fresh timeout. Always starts from a peek (so a
|
||||
prior *completed* timeout cycle is lazily reset first, mirroring how
|
||||
every real call site checks peek_throttle before record_attempt)."""
|
||||
for _ in RESEND_BACKOFF_SECONDS:
|
||||
peeked = await peek_throttle(db_session, ip, action)
|
||||
assert peeked.allowed is True
|
||||
await record_attempt(db_session, ip, action, RESEND_BACKOFF_SECONDS)
|
||||
state = await IpThrottleRepository(db_session).get_state(ip, action)
|
||||
if state.next_allowed_at is not None:
|
||||
next_allowed_at = ensure_aware_utc(state.next_allowed_at)
|
||||
clock.advance((next_allowed_at - clock.now).total_seconds())
|
||||
# One more attempt now that every stage is consumed - this is the one
|
||||
# that exhausts the array and enters a timeout.
|
||||
await record_attempt(db_session, ip, action, RESEND_BACKOFF_SECONDS)
|
||||
|
||||
|
||||
async def test_first_attempt_is_always_allowed(db_session, clock):
|
||||
ip = _unique_ip()
|
||||
result = await peek_throttle(db_session, ip, ThrottleAction.RESEND_VERIFICATION)
|
||||
assert result.allowed is True
|
||||
assert result.banned is False
|
||||
|
||||
|
||||
async def test_resend_backoff_stages_match_spec_exactly(db_session, clock):
|
||||
ip = _unique_ip()
|
||||
action = ThrottleAction.RESEND_VERIFICATION
|
||||
|
||||
for expected_wait in RESEND_BACKOFF_SECONDS:
|
||||
result = await peek_throttle(db_session, ip, action)
|
||||
assert result.allowed is True
|
||||
await record_attempt(db_session, ip, action, RESEND_BACKOFF_SECONDS)
|
||||
|
||||
blocked = await peek_throttle(db_session, ip, action)
|
||||
assert blocked.allowed is False
|
||||
assert blocked.retry_after_seconds == expected_wait
|
||||
|
||||
clock.advance(expected_wait)
|
||||
|
||||
|
||||
async def test_resend_exhausting_stages_enters_first_timeout(db_session, clock):
|
||||
ip = _unique_ip()
|
||||
action = ThrottleAction.RESEND_RESET
|
||||
|
||||
await _exhaust_stages_into_timeout(db_session, clock, ip, action)
|
||||
|
||||
blocked = await peek_throttle(db_session, ip, action)
|
||||
assert blocked.allowed is False
|
||||
assert blocked.retry_after_seconds == TIMEOUT_LADDER_SECONDS[0]
|
||||
|
||||
|
||||
async def test_timeout_expires_resets_attempts_but_keeps_offense_memory(db_session, clock):
|
||||
ip = _unique_ip()
|
||||
action = ThrottleAction.RESEND_RESET
|
||||
|
||||
await _exhaust_stages_into_timeout(db_session, clock, ip, action)
|
||||
|
||||
repo = IpThrottleRepository(db_session)
|
||||
state = await repo.get_state(ip, action)
|
||||
assert state.offense_count == 1
|
||||
|
||||
clock.advance(TIMEOUT_LADDER_SECONDS[0])
|
||||
result = await peek_throttle(db_session, ip, action)
|
||||
assert result.allowed is True
|
||||
|
||||
state = await repo.get_state(ip, action)
|
||||
assert state.attempt_count == 0
|
||||
assert state.offense_count == 1 # memory kept, exactly as specified
|
||||
|
||||
|
||||
async def test_repeat_offense_uses_next_longer_timeout(db_session, clock):
|
||||
ip = _unique_ip()
|
||||
action = ThrottleAction.RESEND_RESET
|
||||
|
||||
await _exhaust_stages_into_timeout(db_session, clock, ip, action) # offense #1 -> 30min
|
||||
clock.advance(TIMEOUT_LADDER_SECONDS[0])
|
||||
|
||||
await _exhaust_stages_into_timeout(db_session, clock, ip, action) # offense #2 -> 1h
|
||||
blocked = await peek_throttle(db_session, ip, action)
|
||||
assert blocked.retry_after_seconds == TIMEOUT_LADDER_SECONDS[1]
|
||||
|
||||
|
||||
async def test_escalation_past_the_ladder_results_in_permanent_ban(db_session, clock):
|
||||
ip = _unique_ip()
|
||||
action = ThrottleAction.RESEND_RESET
|
||||
|
||||
for i, timeout_seconds in enumerate(TIMEOUT_LADDER_SECONDS):
|
||||
await _exhaust_stages_into_timeout(db_session, clock, ip, action)
|
||||
clock.advance(timeout_seconds)
|
||||
result = await peek_throttle(db_session, ip, action)
|
||||
assert result.banned is False, f"should not be banned yet after offense {i + 1}"
|
||||
|
||||
# One more full cycle exhausts past the ladder entirely -> permanent ban.
|
||||
await _exhaust_stages_into_timeout(db_session, clock, ip, action)
|
||||
result = await peek_throttle(db_session, ip, action)
|
||||
assert result.banned is True
|
||||
assert result.allowed is False
|
||||
|
||||
|
||||
async def test_login_five_instant_attempts_then_escalating_delays(db_session, clock):
|
||||
ip = _unique_ip()
|
||||
action = ThrottleAction.FAILED_LOGIN
|
||||
|
||||
for _ in range(5):
|
||||
result = await peek_throttle(db_session, ip, action)
|
||||
assert result.allowed is True
|
||||
await record_attempt(db_session, ip, action, LOGIN_BACKOFF_SECONDS)
|
||||
# All 5 were genuinely free - no wait was ever imposed before any of them.
|
||||
|
||||
blocked = await peek_throttle(db_session, ip, action)
|
||||
assert blocked.allowed is False
|
||||
assert blocked.retry_after_seconds == 5 # first real delay stage, gating attempt 6
|
||||
|
||||
|
||||
async def test_login_all_delay_stages_match_spec_in_order(db_session, clock):
|
||||
ip = _unique_ip()
|
||||
action = ThrottleAction.FAILED_LOGIN
|
||||
|
||||
for _ in range(5):
|
||||
await record_attempt(db_session, ip, action, LOGIN_BACKOFF_SECONDS)
|
||||
|
||||
expected_delays = [5, 15, 30, 60, 120, 300, 900]
|
||||
for expected_wait in expected_delays:
|
||||
blocked = await peek_throttle(db_session, ip, action)
|
||||
assert blocked.retry_after_seconds == expected_wait
|
||||
clock.advance(expected_wait)
|
||||
await record_attempt(db_session, ip, action, LOGIN_BACKOFF_SECONDS)
|
||||
|
||||
# That was the 12th recorded attempt (5 free + 7 delayed) - stages are
|
||||
# now exhausted, so the IP itself enters its first timeout.
|
||||
blocked = await peek_throttle(db_session, ip, action)
|
||||
assert blocked.retry_after_seconds == TIMEOUT_LADDER_SECONDS[0]
|
||||
|
||||
|
||||
async def test_ban_blocks_every_action_type_for_that_ip(db_session, clock):
|
||||
ip = _unique_ip()
|
||||
action = ThrottleAction.RESEND_RESET
|
||||
|
||||
for timeout_seconds in TIMEOUT_LADDER_SECONDS:
|
||||
await _exhaust_stages_into_timeout(db_session, clock, ip, action)
|
||||
clock.advance(timeout_seconds)
|
||||
await _exhaust_stages_into_timeout(db_session, clock, ip, action) # permanent ban
|
||||
|
||||
# A totally different action from the same IP is also blocked - bans are
|
||||
# global per-IP, not scoped to the action that triggered them.
|
||||
login_result = await peek_throttle(db_session, ip, ThrottleAction.FAILED_LOGIN)
|
||||
assert login_result.banned is True
|
||||
|
||||
|
||||
async def test_reset_on_success_clears_stage_but_not_offense_count(db_session, clock):
|
||||
ip = _unique_ip()
|
||||
action = ThrottleAction.FAILED_LOGIN
|
||||
|
||||
for _ in range(6):
|
||||
await record_attempt(db_session, ip, action, LOGIN_BACKOFF_SECONDS)
|
||||
|
||||
repo = IpThrottleRepository(db_session)
|
||||
state = await repo.get_state(ip, action)
|
||||
assert state.attempt_count == 6
|
||||
|
||||
await reset_on_success(db_session, ip, action)
|
||||
state = await repo.get_state(ip, action)
|
||||
assert state.attempt_count == 0
|
||||
assert state.next_allowed_at is None
|
||||
|
||||
result = await peek_throttle(db_session, ip, action)
|
||||
assert result.allowed is True
|
||||
|
||||
|
||||
async def test_admin_unban_gives_a_clean_slate(db_session, clock):
|
||||
ip = _unique_ip()
|
||||
action = ThrottleAction.RESEND_RESET
|
||||
|
||||
for timeout_seconds in TIMEOUT_LADDER_SECONDS:
|
||||
await _exhaust_stages_into_timeout(db_session, clock, ip, action)
|
||||
clock.advance(timeout_seconds)
|
||||
await _exhaust_stages_into_timeout(db_session, clock, ip, action) # permanent ban
|
||||
|
||||
result = await peek_throttle(db_session, ip, action)
|
||||
assert result.banned is True
|
||||
|
||||
repo = IpThrottleRepository(db_session)
|
||||
cleared = await repo.clear_ban_and_state(ip)
|
||||
assert cleared is True
|
||||
|
||||
result = await peek_throttle(db_session, ip, action)
|
||||
assert result.allowed is True
|
||||
assert result.banned is False
|
||||
|
||||
state = await repo.get_state(ip, action)
|
||||
assert state.offense_count == 0 # a real pardon, not just lifting the ban
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Anthropic/Ollama providers, with the SDK/HTTP layer mocked - these never
|
||||
run against a real paid API in the test suite. Verifies the structured-
|
||||
output + repair-loop wiring actually works, not just that it imports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.analysis.llm.base import LLMResponseError
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
class _Toy(BaseModel):
|
||||
answer: str
|
||||
|
||||
|
||||
def _settings(**overrides) -> Settings:
|
||||
defaults = {
|
||||
"llm_provider": "anthropic",
|
||||
"anthropic_api_key": "sk-test",
|
||||
"anthropic_model": "claude-test",
|
||||
"llm_max_retries": 1,
|
||||
"llm_max_tokens_per_request": 100,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return Settings(**defaults)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_provider_parses_tool_use_response():
|
||||
from app.analysis.llm.anthropic_provider import AnthropicLLMProvider
|
||||
|
||||
provider = AnthropicLLMProvider(_settings())
|
||||
fake_response = SimpleNamespace(
|
||||
content=[SimpleNamespace(type="tool_use", input={"answer": "42"})]
|
||||
)
|
||||
with patch.object(provider._client.messages, "create", AsyncMock(return_value=fake_response)):
|
||||
result = await provider.generate_structured("system", "user", _Toy)
|
||||
|
||||
assert result.answer == "42"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_provider_retries_then_raises_on_missing_tool_use():
|
||||
from app.analysis.llm.anthropic_provider import AnthropicLLMProvider
|
||||
|
||||
provider = AnthropicLLMProvider(_settings(llm_max_retries=1))
|
||||
fake_response = SimpleNamespace(content=[SimpleNamespace(type="text", text="oops")])
|
||||
with patch.object(
|
||||
provider._client.messages, "create", AsyncMock(return_value=fake_response)
|
||||
) as mock_create:
|
||||
with pytest.raises(LLMResponseError):
|
||||
await provider.generate_structured("system", "user", _Toy)
|
||||
|
||||
assert mock_create.call_count == 2 # initial attempt + 1 retry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_provider_generate_text_joins_text_blocks():
|
||||
from app.analysis.llm.anthropic_provider import AnthropicLLMProvider
|
||||
|
||||
provider = AnthropicLLMProvider(_settings())
|
||||
fake_response = SimpleNamespace(
|
||||
content=[
|
||||
SimpleNamespace(type="text", text="Hello"),
|
||||
SimpleNamespace(type="text", text="world"),
|
||||
]
|
||||
)
|
||||
with patch.object(provider._client.messages, "create", AsyncMock(return_value=fake_response)):
|
||||
text = await provider.generate_text("system", "user")
|
||||
|
||||
assert text == "Hello\nworld"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ollama_provider_parses_json_response():
|
||||
from app.analysis.llm.ollama_provider import OllamaLLMProvider
|
||||
|
||||
settings = Settings(
|
||||
llm_provider="ollama",
|
||||
ollama_base_url="http://ollama.local:11434",
|
||||
ollama_model="llama-test",
|
||||
llm_max_retries=1,
|
||||
)
|
||||
provider = OllamaLLMProvider(settings)
|
||||
|
||||
with respx.mock:
|
||||
respx.post("http://ollama.local:11434/api/chat").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"message": {"content": json.dumps({"answer": "42"})}}
|
||||
)
|
||||
)
|
||||
result = await provider.generate_structured("system", "user", _Toy)
|
||||
|
||||
assert result.answer == "42"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ollama_provider_retries_then_raises_on_invalid_json():
|
||||
from app.analysis.llm.ollama_provider import OllamaLLMProvider
|
||||
|
||||
settings = Settings(
|
||||
llm_provider="ollama",
|
||||
ollama_base_url="http://ollama.local:11434",
|
||||
ollama_model="llama-test",
|
||||
llm_max_retries=1,
|
||||
)
|
||||
provider = OllamaLLMProvider(settings)
|
||||
|
||||
with respx.mock:
|
||||
route = respx.post("http://ollama.local:11434/api/chat").mock(
|
||||
return_value=httpx.Response(200, json={"message": {"content": "not json"}})
|
||||
)
|
||||
with pytest.raises(LLMResponseError):
|
||||
await provider.generate_structured("system", "user", _Toy)
|
||||
|
||||
assert route.call_count == 2
|
||||
|
||||
|
||||
def _gemini_settings(**overrides) -> Settings:
|
||||
defaults = {
|
||||
"llm_provider": "gemini",
|
||||
"gemini_api_key": "test-key",
|
||||
"gemini_model": "gemini-test",
|
||||
"llm_max_retries": 1,
|
||||
"llm_max_tokens_per_request": 100,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return Settings(**defaults)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_provider_parses_structured_response():
|
||||
from app.analysis.llm.gemini_provider import GeminiLLMProvider
|
||||
|
||||
provider = GeminiLLMProvider(_gemini_settings())
|
||||
fake_response = SimpleNamespace(parsed=_Toy(answer="42"))
|
||||
with patch.object(
|
||||
provider._client.aio.models, "generate_content", AsyncMock(return_value=fake_response)
|
||||
):
|
||||
result = await provider.generate_structured("system", "user", _Toy)
|
||||
|
||||
assert result.answer == "42"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_provider_retries_then_raises_when_unparsed():
|
||||
from app.analysis.llm.gemini_provider import GeminiLLMProvider
|
||||
|
||||
provider = GeminiLLMProvider(_gemini_settings(llm_max_retries=1))
|
||||
fake_response = SimpleNamespace(parsed=None)
|
||||
with patch.object(
|
||||
provider._client.aio.models, "generate_content", AsyncMock(return_value=fake_response)
|
||||
) as mock_generate:
|
||||
with pytest.raises(LLMResponseError):
|
||||
await provider.generate_structured("system", "user", _Toy)
|
||||
|
||||
assert mock_generate.call_count == 2 # initial attempt + 1 retry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_provider_generate_text_returns_response_text():
|
||||
from app.analysis.llm.gemini_provider import GeminiLLMProvider
|
||||
|
||||
provider = GeminiLLMProvider(_gemini_settings())
|
||||
fake_response = SimpleNamespace(text="Hello world")
|
||||
with patch.object(
|
||||
provider._client.aio.models, "generate_content", AsyncMock(return_value=fake_response)
|
||||
):
|
||||
text = await provider.generate_text("system", "user")
|
||||
|
||||
assert text == "Hello world"
|
||||
@@ -0,0 +1,160 @@
|
||||
"""MockLLMProvider: every analysis task's response schema must come back
|
||||
valid and genuinely reflect the evidence passed in - never an empty
|
||||
placeholder unrelated to the input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.analysis.llm.mock import MockLLMProvider
|
||||
from app.prompts.alert_summarization import AlertSummary
|
||||
from app.prompts.base import build_user_prompt
|
||||
from app.prompts.change_significance import ChangeSignificanceAssessment
|
||||
from app.prompts.extraction import ExtractionResult
|
||||
from app.prompts.relevance import RelevanceAssessment
|
||||
from app.prompts.report_generation import ReportContent
|
||||
from app.prompts.synthesis import SynthesisResult
|
||||
|
||||
provider = MockLLMProvider()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relevance_matches_focus_keyword():
|
||||
prompt = build_user_prompt(
|
||||
"assess",
|
||||
{
|
||||
"monitoring_focus": "electric vehicle manufacturing expansion",
|
||||
"document_text": "The company announced a new manufacturing facility for electric vehicles.",
|
||||
},
|
||||
)
|
||||
result = await provider.generate_structured("system", prompt, RelevanceAssessment)
|
||||
assert result.is_relevant is True
|
||||
assert result.matches_focus is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extraction_pulls_first_sentence_as_signal():
|
||||
prompt = build_user_prompt(
|
||||
"extract", {"document_text": "Acme Corp opened a new facility. It will employ 200 people."}
|
||||
)
|
||||
result = await provider.generate_structured("system", prompt, ExtractionResult)
|
||||
assert len(result.signals) == 1
|
||||
assert "Acme Corp opened a new facility" in result.signals[0].description
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_synthesis_requires_multiple_signals():
|
||||
prompt_one = build_user_prompt("synthesize", {"signals": [{"description": "a"}]})
|
||||
result_one = await provider.generate_structured("system", prompt_one, SynthesisResult)
|
||||
assert result_one.conclusions == []
|
||||
|
||||
prompt_two = build_user_prompt(
|
||||
"synthesize", {"signals": [{"description": "a"}, {"description": "b"}]}
|
||||
)
|
||||
result_two = await provider.generate_structured("system", prompt_two, SynthesisResult)
|
||||
assert len(result_two.conclusions) == 1
|
||||
assert result_two.conclusions[0].source_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_reflects_evidence_counts():
|
||||
prompt = build_user_prompt(
|
||||
"report",
|
||||
{
|
||||
"company_profile": {"name": "Acme Corp"},
|
||||
"source_documents": [
|
||||
{
|
||||
"id": "d1",
|
||||
"title": "Job posting",
|
||||
"url": "https://x.com/1",
|
||||
"source_type": "job_posting",
|
||||
"retrieved_date": "2026-01-01",
|
||||
}
|
||||
],
|
||||
"detected_changes": [
|
||||
{
|
||||
"id": "c1",
|
||||
"summary": "New job posting detected",
|
||||
"change_type": "new_document",
|
||||
"severity": "medium",
|
||||
"confidence_score": 0.6,
|
||||
"created_at": "2026-01-01",
|
||||
}
|
||||
],
|
||||
"sources_that_failed_to_collect": ["Broken Source"],
|
||||
},
|
||||
)
|
||||
result = await provider.generate_structured("system", prompt, ReportContent)
|
||||
assert "Acme Corp" in result.executive_summary
|
||||
assert "1" in result.executive_summary # document/change counts mentioned
|
||||
assert len(result.recent_developments) == 1
|
||||
assert len(result.hiring_signals) == 1
|
||||
assert any("Broken Source" in u for u in result.unknowns_and_missing_data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_grounds_overview_in_discovered_profile_even_with_no_documents():
|
||||
"""The bug this covers: a report generated before any monitoring run had
|
||||
collected evidence used to come back near-empty even though the company's
|
||||
discovered profile (from onboarding) had real data. That profile data
|
||||
must now ground company_overview/market_positioning."""
|
||||
prompt = build_user_prompt(
|
||||
"report",
|
||||
{
|
||||
"company_profile": {
|
||||
"name": "Stripe",
|
||||
"description": "Stripe builds economic infrastructure for the internet.",
|
||||
"industry": "Financial infrastructure",
|
||||
"headquarters": "South San Francisco, California",
|
||||
"competitors": ["PayPal"],
|
||||
"aliases": [],
|
||||
},
|
||||
"source_documents": [],
|
||||
"detected_changes": [],
|
||||
"sources_that_failed_to_collect": [],
|
||||
},
|
||||
)
|
||||
result = await provider.generate_structured("system", prompt, ReportContent)
|
||||
assert "economic infrastructure" in result.company_overview
|
||||
assert "South San Francisco" in result.company_overview
|
||||
assert "PayPal" in result.market_positioning
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_significance_reflects_deterministic_severity():
|
||||
prompt = build_user_prompt(
|
||||
"assess",
|
||||
{
|
||||
"change_type": "leadership_change",
|
||||
"deterministic_severity": "high",
|
||||
"deterministic_confidence": 0.8,
|
||||
},
|
||||
)
|
||||
result = await provider.generate_structured("system", prompt, ChangeSignificanceAssessment)
|
||||
assert result.is_meaningful is True
|
||||
assert result.should_notify is True
|
||||
assert "high" in result.why_it_matters
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_summary_title_under_100_chars():
|
||||
prompt = build_user_prompt(
|
||||
"summarize",
|
||||
{
|
||||
"company_name": "Acme Corp",
|
||||
"change_type": "price_change",
|
||||
"severity": "medium",
|
||||
"confidence": 0.6,
|
||||
},
|
||||
)
|
||||
result = await provider.generate_structured("system", prompt, AlertSummary)
|
||||
assert len(result.title) <= 100
|
||||
assert "Acme Corp" in result.title
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_text_does_not_crash():
|
||||
prompt = build_user_prompt("do something", {"a": 1})
|
||||
text = await provider.generate_text("system", prompt)
|
||||
assert isinstance(text, str)
|
||||
assert len(text) > 0
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Monitoring run endpoints, exercised via HTTP with CELERY_TASK_ALWAYS_EAGER
|
||||
so `.delay()` runs the task synchronously in-process (see app/tasks/base.py
|
||||
for why that needs a threaded asyncio bridge to work from an async route
|
||||
handler). No live network: every collector's discovery/collection call is
|
||||
respx-mocked."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
|
||||
def _register_and_login(client) -> dict[str, str]:
|
||||
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def _create_company(client, headers, **overrides):
|
||||
payload = {
|
||||
"name": f"Run Test Co {uuid.uuid4().hex[:6]}",
|
||||
"frequency_type": "weekly",
|
||||
**overrides,
|
||||
}
|
||||
return client.post("/api/v1/companies", json=payload, headers=headers).json()
|
||||
|
||||
|
||||
def _mock_empty_discovery():
|
||||
"""Every discoverable collector type returns nothing (or a harmless
|
||||
stub), so a first run doesn't need per-source mocks for whatever
|
||||
discovery happens to find. RSS (Google News) and GOV_CONTRACT
|
||||
(USASpending) are always discovered unconditionally - see
|
||||
discovery_service.py's _PREVIEWABLE_TYPES - so their collect() calls
|
||||
need mocking here too, unlike GitHub/SEC which discover.() itself."""
|
||||
respx.get("https://api.github.com/search/users").mock(
|
||||
return_value=httpx.Response(200, text=json.dumps({"items": []}))
|
||||
)
|
||||
respx.get("https://www.sec.gov/cgi-bin/browse-edgar").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text='<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>'
|
||||
)
|
||||
)
|
||||
respx.get(url__regex=r"https://news\.google\.com/rss/search.*").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
text=(
|
||||
'<?xml version="1.0"?><rss version="2.0"><channel><title>Google News</title>'
|
||||
"<item><title>Test</title><link>https://example.com/news</link>"
|
||||
"<description>Test news item</description></item></channel></rss>"
|
||||
),
|
||||
)
|
||||
)
|
||||
respx.post("https://api.usaspending.gov/api/v2/search/spending_by_award/").mock(
|
||||
return_value=httpx.Response(200, json={"results": []})
|
||||
)
|
||||
|
||||
|
||||
def test_run_now_executes_and_reports_final_status(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
with respx.mock:
|
||||
_mock_empty_discovery()
|
||||
resp = client.post(f"/api/v1/companies/{company['id']}/run", headers=headers)
|
||||
|
||||
assert resp.status_code == 202
|
||||
run = resp.json()
|
||||
assert run["trigger_type"] == "manual"
|
||||
|
||||
# Eager mode means the task already finished by the time .delay() returns.
|
||||
detail = client.get(f"/api/v1/runs/{run['id']}", headers=headers).json()
|
||||
assert detail["status"] == "successful"
|
||||
assert detail["completed_at"] is not None
|
||||
|
||||
|
||||
async def test_run_now_is_idempotent_for_an_active_run(db_session, settings):
|
||||
"""A run-now call while one is already queued/running returns that same
|
||||
run instead of enqueuing a duplicate. Exercised at the service layer
|
||||
directly: under CELERY_TASK_ALWAYS_EAGER a `.delay()` call runs the task
|
||||
to completion before returning, so an HTTP-level test can never observe
|
||||
an in-flight run to dedupe against."""
|
||||
from app.models.company import Company
|
||||
from app.models.enums import MonitoringRunTrigger
|
||||
from app.models.monitor_configuration import MonitorConfiguration
|
||||
from app.repositories.monitoring_run_repository import MonitoringRunRepository
|
||||
from app.services import monitoring_service
|
||||
|
||||
user_id = uuid.uuid4()
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="Idempotency Co",
|
||||
slug=f"idempotency-co-{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db_session.add(company)
|
||||
db_session.add(MonitorConfiguration(company_id=company.id))
|
||||
await db_session.commit()
|
||||
|
||||
run_repo = MonitoringRunRepository(db_session)
|
||||
existing = await run_repo.create(
|
||||
company_id=company.id, trigger_type=MonitoringRunTrigger.SCHEDULED
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
with patch("app.tasks.collection.run_monitoring.delay") as mock_delay:
|
||||
result = await monitoring_service.enqueue_run_now(db_session, settings, user_id, company.id)
|
||||
|
||||
assert result.id == existing.id
|
||||
mock_delay.assert_not_called()
|
||||
|
||||
|
||||
async def test_run_now_enforces_daily_manual_run_cap(db_session, settings):
|
||||
from app.core.errors import RateLimitedError
|
||||
from app.models.company import Company
|
||||
from app.models.enums import MonitoringRunStatus, MonitoringRunTrigger
|
||||
from app.models.monitor_configuration import MonitorConfiguration
|
||||
from app.repositories.monitoring_run_repository import MonitoringRunRepository
|
||||
from app.services import monitoring_service
|
||||
|
||||
user_id = uuid.uuid4()
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="Rate Limited Co",
|
||||
slug=f"rate-limited-co-{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db_session.add(company)
|
||||
db_session.add(MonitorConfiguration(company_id=company.id))
|
||||
await db_session.commit()
|
||||
|
||||
run_repo = MonitoringRunRepository(db_session)
|
||||
for _ in range(2):
|
||||
run = await run_repo.create(company_id=company.id, trigger_type=MonitoringRunTrigger.MANUAL)
|
||||
await run_repo.mark_finished(run, status=MonitoringRunStatus.SUCCESSFUL, error_summary=None)
|
||||
await db_session.commit()
|
||||
|
||||
capped_settings = settings.model_copy(update={"max_manual_runs_per_day": 2})
|
||||
|
||||
with pytest.raises(RateLimitedError):
|
||||
await monitoring_service.enqueue_run_now(db_session, capped_settings, user_id, company.id)
|
||||
|
||||
|
||||
def test_run_history_and_single_run_scoped_to_owner(client):
|
||||
owner_headers = _register_and_login(client)
|
||||
other_headers = _register_and_login(client)
|
||||
company = _create_company(client, owner_headers)
|
||||
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
with respx.mock:
|
||||
_mock_empty_discovery()
|
||||
run = client.post(
|
||||
f"/api/v1/companies/{company['id']}/run", headers=owner_headers
|
||||
).json()
|
||||
|
||||
assert (
|
||||
client.get(f"/api/v1/companies/{company['id']}/runs", headers=other_headers).status_code
|
||||
== 404
|
||||
)
|
||||
assert client.get(f"/api/v1/runs/{run['id']}", headers=other_headers).status_code == 404
|
||||
assert client.get(f"/api/v1/runs/{run['id']}", headers=owner_headers).status_code == 200
|
||||
|
||||
|
||||
def test_successful_first_run_generates_a_baseline_report(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
client.post(
|
||||
f"/api/v1/companies/{company['id']}/sources",
|
||||
json={
|
||||
"source_type": "custom_url",
|
||||
"name": "Pricing",
|
||||
"base_url": "https://example.com/pricing",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
with respx.mock:
|
||||
_mock_empty_discovery()
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/pricing").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
html="<html><head><title>Pricing</title></head><body>"
|
||||
"<article><h1>Pricing</h1><p>Plans start at $10/month.</p></article>"
|
||||
"</body></html>",
|
||||
)
|
||||
)
|
||||
client.post(f"/api/v1/companies/{company['id']}/run", headers=headers)
|
||||
|
||||
reports = client.get(f"/api/v1/companies/{company['id']}/reports", headers=headers).json()
|
||||
assert len(reports) == 1
|
||||
assert reports[0]["report_type"] == "baseline"
|
||||
assert reports[0]["model_provider"] == "mock"
|
||||
|
||||
|
||||
def test_manual_run_does_not_disrupt_next_scheduled_run(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
original_next_run = company["monitor_configuration"]["next_run"]
|
||||
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
with respx.mock:
|
||||
_mock_empty_discovery()
|
||||
client.post(f"/api/v1/companies/{company['id']}/run", headers=headers)
|
||||
|
||||
refreshed = client.get(f"/api/v1/companies/{company['id']}", headers=headers).json()
|
||||
assert refreshed["monitor_configuration"]["next_run"] == original_next_run
|
||||
assert refreshed["monitor_configuration"]["last_run"] is not None
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.change_detection.noise_filters import strip_noise
|
||||
|
||||
|
||||
def test_strips_dynamic_timestamps():
|
||||
text = "Page content.\nLast modified: 2026-03-14 09:00:00\nMore content."
|
||||
cleaned = strip_noise(text)
|
||||
assert "2026-03-14" not in cleaned
|
||||
assert "Page content." in cleaned
|
||||
assert "More content." in cleaned
|
||||
|
||||
|
||||
def test_strips_cookie_banner_boilerplate():
|
||||
text = "We use cookies to improve your experience. Real content here."
|
||||
cleaned = strip_noise(text)
|
||||
assert "cookies" not in cleaned.lower()
|
||||
assert "Real content here." in cleaned
|
||||
|
||||
|
||||
def test_strips_copyright_year():
|
||||
text = "Footer text. Copyright (c) 2026 Acme Corp. All rights reserved."
|
||||
cleaned = strip_noise(text)
|
||||
assert "2026" not in cleaned
|
||||
|
||||
|
||||
def test_leaves_ordinary_prose_untouched():
|
||||
text = "Acme Corp announced a new electric vehicle platform this week."
|
||||
cleaned = strip_noise(text)
|
||||
assert "Acme Corp announced a new electric vehicle platform this week." in cleaned
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Notification destination CRUD, ownership isolation, and the
|
||||
company-linking behavior this feature is built around: reusing an existing
|
||||
destination by (type, value) instead of duplicating it, dedupe display,
|
||||
and garbage-collecting a destination once no company references it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
def _register_and_login(client) -> dict[str, str]:
|
||||
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def _create_company(client, headers, name: str | None = None) -> dict:
|
||||
return client.post(
|
||||
"/api/v1/companies",
|
||||
json={"name": name or f"Co {uuid.uuid4().hex[:6]}", "frequency_type": "weekly"},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
|
||||
def test_create_email_destination(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
resp = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["type"] == "email"
|
||||
assert body["verified"] is False
|
||||
assert body["minimum_severity"] == "medium"
|
||||
assert [c["id"] for c in body["companies"]] == [company["id"]]
|
||||
|
||||
|
||||
def test_create_destination_requires_at_least_one_company(client):
|
||||
headers = _register_and_login(client)
|
||||
resp = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={"type": "email", "destination_value": "[email protected]", "company_ids": []},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_create_destination_rejects_a_company_id_the_user_doesnt_own(client):
|
||||
owner_headers = _register_and_login(client)
|
||||
other_headers = _register_and_login(client)
|
||||
other_company = _create_company(client, other_headers)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [other_company["id"]],
|
||||
},
|
||||
headers=owner_headers,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_create_email_destination_rejects_invalid_email(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
resp = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "not-an-email",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_create_sms_destination_rejects_invalid_phone(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
resp = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={"type": "sms", "destination_value": "not-a-phone", "company_ids": [company["id"]]},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_create_sms_destination_accepts_e164(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
resp = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "sms",
|
||||
"destination_value": "+15551234567",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
def test_reusing_the_same_email_for_a_second_company_links_instead_of_duplicating(client):
|
||||
"""The bug this feature exists to fix: the wizard used to create a brand
|
||||
new NotificationDestination row per company even when the email was
|
||||
already registered. Now it must reuse the same row and just add a link."""
|
||||
headers = _register_and_login(client)
|
||||
company_a = _create_company(client, headers, name="Company A")
|
||||
company_b = _create_company(client, headers, name="Company B")
|
||||
|
||||
first = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [company_a["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
second = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]", # different casing on purpose
|
||||
"company_ids": [company_b["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
assert first["id"] == second["id"]
|
||||
listing = client.get("/api/v1/notification-destinations", headers=headers).json()
|
||||
assert len(listing) == 1
|
||||
linked_ids = {c["id"] for c in listing[0]["companies"]}
|
||||
assert linked_ids == {company_a["id"], company_b["id"]}
|
||||
|
||||
|
||||
def test_update_destination_value_resets_verification(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
created = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/notification-destinations/{created['id']}",
|
||||
json={"destination_value": "[email protected]"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["verified"] is False
|
||||
|
||||
|
||||
def test_destinations_scoped_to_owner(client):
|
||||
owner_headers = _register_and_login(client)
|
||||
other_headers = _register_and_login(client)
|
||||
company = _create_company(client, owner_headers)
|
||||
|
||||
created = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=owner_headers,
|
||||
).json()
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/notification-destinations/{created['id']}",
|
||||
json={"enabled": False},
|
||||
headers=other_headers,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_destination(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
created = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
resp = client.delete(f"/api/v1/notification-destinations/{created['id']}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
listing = client.get("/api/v1/notification-destinations", headers=headers).json()
|
||||
assert all(d["id"] != created["id"] for d in listing)
|
||||
|
||||
|
||||
def test_deleting_a_companys_only_destination_link_garbage_collects_it(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = client.delete(f"/api/v1/companies/{company['id']}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
listing = client.get("/api/v1/notification-destinations", headers=headers).json()
|
||||
assert listing == []
|
||||
|
||||
|
||||
def test_deleting_one_of_two_linked_companies_keeps_the_destination(client):
|
||||
headers = _register_and_login(client)
|
||||
company_a = _create_company(client, headers, name="Keep")
|
||||
company_b = _create_company(client, headers, name="Delete me")
|
||||
client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [company_a["id"], company_b["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = client.delete(f"/api/v1/companies/{company_b['id']}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
listing = client.get("/api/v1/notification-destinations", headers=headers).json()
|
||||
assert len(listing) == 1
|
||||
assert [c["id"] for c in listing[0]["companies"]] == [company_a["id"]]
|
||||
|
||||
|
||||
def test_unlink_company_keeps_the_destination_when_other_links_remain(client):
|
||||
headers = _register_and_login(client)
|
||||
company_a = _create_company(client, headers, name="Keep")
|
||||
company_b = _create_company(client, headers, name="Unlink me")
|
||||
created = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [company_a["id"], company_b["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
resp = client.delete(
|
||||
f"/api/v1/notification-destinations/{created['id']}/companies/{company_b['id']}",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
listing = client.get("/api/v1/notification-destinations", headers=headers).json()
|
||||
assert len(listing) == 1
|
||||
assert [c["id"] for c in listing[0]["companies"]] == [company_a["id"]]
|
||||
|
||||
|
||||
def test_unlinking_the_last_company_garbage_collects_the_destination(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
created = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
resp = client.delete(
|
||||
f"/api/v1/notification-destinations/{created['id']}/companies/{company['id']}",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
listing = client.get("/api/v1/notification-destinations", headers=headers).json()
|
||||
assert listing == []
|
||||
|
||||
|
||||
def test_unlink_company_is_scoped_to_the_destinations_owner(client):
|
||||
owner_headers = _register_and_login(client)
|
||||
other_headers = _register_and_login(client)
|
||||
company = _create_company(client, owner_headers)
|
||||
created = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=owner_headers,
|
||||
).json()
|
||||
|
||||
resp = client.delete(
|
||||
f"/api/v1/notification-destinations/{created['id']}/companies/{company['id']}",
|
||||
headers=other_headers,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
# The destination and its link both survive the rejected attempt.
|
||||
listing = client.get("/api/v1/notification-destinations", headers=owner_headers).json()
|
||||
assert len(listing) == 1
|
||||
assert [c["id"] for c in listing[0]["companies"]] == [company["id"]]
|
||||
@@ -0,0 +1,121 @@
|
||||
"""POST /notification-destinations/{id}/test - dedicated from
|
||||
test_notification_destinations.py since it exercises actual send-path
|
||||
dispatch (console/SMTP) rather than just CRUD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
def _register_and_login(client) -> dict[str, str]:
|
||||
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def _create_company(client, headers) -> dict:
|
||||
return client.post(
|
||||
"/api/v1/companies",
|
||||
json={"name": f"Co {uuid.uuid4().hex[:6]}", "frequency_type": "weekly"},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
|
||||
def test_test_endpoint_sends_to_email_destination(client, monkeypatch):
|
||||
sent = {}
|
||||
|
||||
class FakeSmtp:
|
||||
def __init__(self, host, port, timeout=10):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
pass
|
||||
|
||||
def login(self, username, password):
|
||||
pass
|
||||
|
||||
def sendmail(self, from_addr, to_addrs, message):
|
||||
sent["to_addrs"] = to_addrs
|
||||
|
||||
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
|
||||
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
destination = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/notification-destinations/{destination['id']}/test", headers=headers
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert body["error"] is None
|
||||
assert sent["to_addrs"] == ["[email protected]"]
|
||||
|
||||
|
||||
def test_test_endpoint_reports_failure(client, monkeypatch):
|
||||
class FailingSmtp:
|
||||
def __init__(self, host, port, timeout=10):
|
||||
raise ConnectionRefusedError("no mailpit running")
|
||||
|
||||
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FailingSmtp)
|
||||
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
destination = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/notification-destinations/{destination['id']}/test", headers=headers
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert "no mailpit running" in body["error"]
|
||||
|
||||
|
||||
def test_test_endpoint_not_owned_returns_404(client):
|
||||
owner_headers = _register_and_login(client)
|
||||
other_headers = _register_and_login(client)
|
||||
company = _create_company(client, owner_headers)
|
||||
destination = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=owner_headers,
|
||||
).json()
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/notification-destinations/{destination['id']}/test", headers=other_headers
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Notification provider unit tests: console (always succeeds), SMTP (stdlib
|
||||
smtplib mocked, never touches a real socket), Twilio (respx-mocked REST
|
||||
calls), the factory's type routing, and the message builder's per-channel
|
||||
formatting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.models.alert import Alert
|
||||
from app.models.company import Company
|
||||
from app.models.enums import NotificationType, SeverityLevel
|
||||
from app.notifications.base import NotificationMessage
|
||||
from app.notifications.console import ConsoleProvider
|
||||
from app.notifications.factory import get_notification_provider
|
||||
from app.notifications.message_builder import build_alert_message
|
||||
from app.notifications.smtp_email import SmtpEmailProvider
|
||||
from app.notifications.telnyx_sms import TelnyxSmsProvider
|
||||
from app.notifications.twilio_sms import TwilioSmsProvider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_console_provider_always_succeeds():
|
||||
result = await ConsoleProvider().send(
|
||||
NotificationMessage(destination_value="dev@local", subject="Test", body_text="hi")
|
||||
)
|
||||
assert result.success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_smtp_provider_sends_via_smtplib(monkeypatch):
|
||||
sent = {}
|
||||
|
||||
class FakeSmtp:
|
||||
def __init__(self, host, port, timeout=10):
|
||||
sent["host"] = host
|
||||
sent["port"] = port
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
sent["starttls"] = True
|
||||
|
||||
def login(self, username, password):
|
||||
sent["login"] = (username, password)
|
||||
|
||||
def sendmail(self, from_addr, to_addrs, message):
|
||||
sent["from_addr"] = from_addr
|
||||
sent["to_addrs"] = to_addrs
|
||||
sent["message"] = message
|
||||
|
||||
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
|
||||
|
||||
settings = Settings(
|
||||
smtp_host="mailpit", smtp_port=1025, smtp_from_email="[email protected]"
|
||||
)
|
||||
provider = SmtpEmailProvider(settings)
|
||||
result = await provider.send(
|
||||
NotificationMessage(
|
||||
destination_value="[email protected]",
|
||||
subject="Alert: Pricing change",
|
||||
body_text="Plain text body",
|
||||
body_html="<p>HTML body</p>",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert sent["host"] == "mailpit"
|
||||
assert sent["to_addrs"] == ["[email protected]"]
|
||||
assert "starttls" not in sent # smtp_use_tls defaults False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_smtp_provider_reports_failure_without_raising(monkeypatch):
|
||||
class FailingSmtp:
|
||||
def __init__(self, host, port, timeout=10):
|
||||
raise ConnectionRefusedError("no mailpit running")
|
||||
|
||||
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FailingSmtp)
|
||||
|
||||
provider = SmtpEmailProvider(Settings())
|
||||
result = await provider.send(
|
||||
NotificationMessage(destination_value="[email protected]", subject="s", body_text="b")
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "no mailpit running" in result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_twilio_provider_not_configured_fails_cleanly():
|
||||
settings = Settings(twilio_account_sid="", twilio_auth_token="", twilio_from_number="")
|
||||
result = await TwilioSmsProvider(settings).send(
|
||||
NotificationMessage(destination_value="+15551234567", subject="s", body_text="b")
|
||||
)
|
||||
assert result.success is False
|
||||
assert "not configured" in result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_twilio_provider_sends_via_rest_api():
|
||||
settings = Settings(
|
||||
twilio_account_sid="ACxxxx", twilio_auth_token="secret", twilio_from_number="+15559990000"
|
||||
)
|
||||
with respx.mock:
|
||||
respx.post("https://api.twilio.com/2010-04-01/Accounts/ACxxxx/Messages.json").mock(
|
||||
return_value=httpx.Response(201, json={"sid": "SMxxxxx"})
|
||||
)
|
||||
result = await TwilioSmsProvider(settings).send(
|
||||
NotificationMessage(
|
||||
destination_value="+15551234567", subject="s", body_text="Alert text"
|
||||
)
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.external_message_id == "SMxxxxx"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_twilio_provider_surfaces_api_error():
|
||||
settings = Settings(
|
||||
twilio_account_sid="ACxxxx", twilio_auth_token="secret", twilio_from_number="+15559990000"
|
||||
)
|
||||
with respx.mock:
|
||||
respx.post("https://api.twilio.com/2010-04-01/Accounts/ACxxxx/Messages.json").mock(
|
||||
return_value=httpx.Response(400, text="Invalid 'To' number")
|
||||
)
|
||||
result = await TwilioSmsProvider(settings).send(
|
||||
NotificationMessage(destination_value="not-a-number", subject="s", body_text="b")
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "400" in result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telnyx_provider_not_configured_fails_cleanly():
|
||||
settings = Settings(telnyx_api_key="", telnyx_from_number="")
|
||||
result = await TelnyxSmsProvider(settings).send(
|
||||
NotificationMessage(destination_value="+15551234567", subject="s", body_text="b")
|
||||
)
|
||||
assert result.success is False
|
||||
assert "not configured" in result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telnyx_provider_sends_via_rest_api():
|
||||
settings = Settings(telnyx_api_key="KEYxxxx", telnyx_from_number="+15559990000")
|
||||
with respx.mock:
|
||||
respx.post("https://api.telnyx.com/v2/messages").mock(
|
||||
return_value=httpx.Response(200, json={"data": {"id": "msg-abc123"}})
|
||||
)
|
||||
result = await TelnyxSmsProvider(settings).send(
|
||||
NotificationMessage(
|
||||
destination_value="+15551234567", subject="s", body_text="Alert text"
|
||||
)
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.external_message_id == "msg-abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telnyx_provider_surfaces_api_error():
|
||||
settings = Settings(telnyx_api_key="KEYxxxx", telnyx_from_number="+15559990000")
|
||||
with respx.mock:
|
||||
respx.post("https://api.telnyx.com/v2/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
403,
|
||||
json={
|
||||
"errors": [
|
||||
{
|
||||
"code": "40300",
|
||||
"title": "Forbidden",
|
||||
"detail": "The from number is not assigned to a messaging profile.",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
result = await TelnyxSmsProvider(settings).send(
|
||||
NotificationMessage(destination_value="+15551234567", subject="s", body_text="b")
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "403" in result.error
|
||||
assert "messaging profile" in result.error
|
||||
|
||||
|
||||
def test_factory_routes_by_notification_type():
|
||||
settings = Settings()
|
||||
assert get_notification_provider(NotificationType.EMAIL, settings).provider_name == "smtp"
|
||||
assert get_notification_provider(NotificationType.SMS, settings).provider_name == "twilio_sms"
|
||||
assert get_notification_provider(NotificationType.CONSOLE, settings).provider_name == "console"
|
||||
|
||||
|
||||
def test_factory_routes_sms_by_sms_provider_setting():
|
||||
twilio_settings = Settings(sms_provider="twilio")
|
||||
assert (
|
||||
get_notification_provider(NotificationType.SMS, twilio_settings).provider_name
|
||||
== "twilio_sms"
|
||||
)
|
||||
|
||||
telnyx_settings = Settings(sms_provider="telnyx")
|
||||
assert (
|
||||
get_notification_provider(NotificationType.SMS, telnyx_settings).provider_name
|
||||
== "telnyx_sms"
|
||||
)
|
||||
|
||||
|
||||
def _sample_alert() -> Alert:
|
||||
return Alert(
|
||||
id=uuid.uuid4(),
|
||||
company_id=uuid.uuid4(),
|
||||
detected_change_id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
title="New leadership hire announced",
|
||||
summary="A new VP of Engineering was announced on the careers page.",
|
||||
why_it_matters="Signals a scaling push in engineering.",
|
||||
severity=SeverityLevel.HIGH,
|
||||
confidence=0.82,
|
||||
)
|
||||
|
||||
|
||||
def test_message_builder_sms_is_short_and_truncated():
|
||||
company = Company(id=uuid.uuid4(), user_id=uuid.uuid4(), name="Acme Mobility", slug="acme")
|
||||
message = build_alert_message(
|
||||
NotificationType.SMS, "+15551234567", company, _sample_alert(), Settings()
|
||||
)
|
||||
assert len(message.body_text) <= 480
|
||||
assert "HIGH" in message.body_text
|
||||
assert message.body_html is None
|
||||
|
||||
|
||||
def test_message_builder_email_includes_context_and_links():
|
||||
company = Company(id=uuid.uuid4(), user_id=uuid.uuid4(), name="Acme Mobility", slug="acme")
|
||||
alert = _sample_alert()
|
||||
message = build_alert_message(
|
||||
NotificationType.EMAIL, "[email protected]", company, alert, Settings()
|
||||
)
|
||||
assert "Acme Mobility" in message.subject
|
||||
assert alert.summary in message.body_text
|
||||
assert alert.why_it_matters in message.body_text
|
||||
assert "/alerts" in message.body_text
|
||||
assert "/settings" in message.body_text
|
||||
assert message.body_html is not None
|
||||
assert alert.summary in message.body_html
|
||||
@@ -0,0 +1,89 @@
|
||||
"""The suite disables the rate limiter globally (see conftest.py) so the many
|
||||
auth calls other tests make don't trip real limits. This test re-enables it
|
||||
temporarily to verify the limiter itself actually works."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.rate_limit import _client_ip_key, limiter
|
||||
|
||||
|
||||
def _build_request(headers: dict[str, str], client_ip: str) -> Request:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()],
|
||||
"client": (client_ip, 12345),
|
||||
}
|
||||
return Request(scope)
|
||||
|
||||
|
||||
def test_client_ip_key_uses_direct_peer_when_no_proxy_header_configured():
|
||||
# Default test settings have trusted_proxy_ip_header="" - the header
|
||||
# must be ignored even if a client sends it, or a spoofed header could
|
||||
# forge any rate-limit identity with no proxy actually in front.
|
||||
request = _build_request({"CF-Connecting-IP": "203.0.113.5"}, client_ip="10.0.0.9")
|
||||
assert _client_ip_key(request) == "10.0.0.9"
|
||||
|
||||
|
||||
def test_client_ip_key_honors_configured_proxy_header(monkeypatch):
|
||||
# Once deployed behind Cloudflare, trusted_proxy_ip_header="CF-Connecting-IP"
|
||||
# must make the limiter key off the real visitor, not Nginx's own address -
|
||||
# this is the exact bug slowapi's default get_remote_address had.
|
||||
settings = get_settings().model_copy(update={"trusted_proxy_ip_header": "CF-Connecting-IP"})
|
||||
monkeypatch.setattr("app.core.rate_limit.get_settings", lambda: settings)
|
||||
|
||||
request = _build_request({"CF-Connecting-IP": "203.0.113.5"}, client_ip="10.0.0.9")
|
||||
assert _client_ip_key(request) == "203.0.113.5"
|
||||
|
||||
|
||||
def test_register_endpoint_enforces_rate_limit(client):
|
||||
limiter.enabled = True
|
||||
try:
|
||||
responses = [
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": f"rl-{uuid.uuid4().hex[:10]}@example.com",
|
||||
"password": "correct-horse-1",
|
||||
"display_name": "Rate Limit Test",
|
||||
},
|
||||
)
|
||||
for _ in range(6)
|
||||
]
|
||||
finally:
|
||||
limiter.enabled = False
|
||||
|
||||
statuses = [r.status_code for r in responses]
|
||||
assert 429 in statuses, f"Expected a 429 among {statuses} after 6 rapid registrations"
|
||||
|
||||
|
||||
def test_create_company_enforces_rate_limit(client):
|
||||
email = f"rl-company-{uuid.uuid4().hex[:10]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "RL Test"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
limiter.enabled = True
|
||||
try:
|
||||
responses = [
|
||||
client.post(
|
||||
"/api/v1/companies",
|
||||
json={"name": f"RL Co {i}", "frequency_type": "weekly"},
|
||||
headers=headers,
|
||||
)
|
||||
for i in range(22)
|
||||
]
|
||||
finally:
|
||||
limiter.enabled = False
|
||||
|
||||
statuses = [r.status_code for r in responses]
|
||||
assert 429 in statuses, f"Expected a 429 among {statuses} after 22 rapid company creations"
|
||||
@@ -0,0 +1,69 @@
|
||||
"""generate_report(): the company_enrichment evidence block reaches the
|
||||
prompt exactly like company_profile does, and is an honest empty dict
|
||||
when no enrichment data is available - never fabricated."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.prompts.base import extract_evidence_block
|
||||
from app.prompts.report_generation import ReportContent, SwotAnalysis, generate_report
|
||||
|
||||
|
||||
class _CapturingLLMProvider:
|
||||
provider_name = "capturing"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.last_user_prompt: str | None = None
|
||||
|
||||
async def generate_structured(self, system_prompt, user_prompt, response_model):
|
||||
self.last_user_prompt = user_prompt
|
||||
return ReportContent(
|
||||
executive_summary="",
|
||||
company_overview="",
|
||||
market_positioning="",
|
||||
customer_sentiment="",
|
||||
competitor_comparison="",
|
||||
swot=SwotAnalysis(),
|
||||
methodology="",
|
||||
limitations="",
|
||||
)
|
||||
|
||||
async def generate_text(self, system_prompt, user_prompt) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
async def _generate(llm: _CapturingLLMProvider, **kwargs) -> None:
|
||||
await generate_report(
|
||||
llm,
|
||||
company_name="Acme Corp",
|
||||
company_aliases=[],
|
||||
competitors=[],
|
||||
monitoring_focus=None,
|
||||
industry=None,
|
||||
documents=[],
|
||||
detected_changes=[],
|
||||
sources_failed=[],
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrichment_data_reaches_the_prompt_as_a_named_evidence_block():
|
||||
llm = _CapturingLLMProvider()
|
||||
await _generate(
|
||||
llm, enrichment={"employee_count": "1001-5000", "funding": {"total_raised": "$1M"}}
|
||||
)
|
||||
|
||||
evidence = extract_evidence_block(llm.last_user_prompt)
|
||||
assert evidence["company_enrichment"]["employee_count"] == "1001-5000"
|
||||
assert evidence["company_enrichment"]["funding"]["total_raised"] == "$1M"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_enrichment_data_is_an_honest_empty_block_not_fabricated():
|
||||
llm = _CapturingLLMProvider()
|
||||
await _generate(llm) # enrichment defaults to None
|
||||
|
||||
evidence = extract_evidence_block(llm.last_user_prompt)
|
||||
assert evidence["company_enrichment"] == {}
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Reports API: ownership isolation, manual generation, and the raw
|
||||
markdown/json export endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
def _register_and_login(client) -> dict[str, str]:
|
||||
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def _create_company(client, headers):
|
||||
return client.post(
|
||||
"/api/v1/companies",
|
||||
json={"name": f"Report Co {uuid.uuid4().hex[:6]}", "frequency_type": "weekly"},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
|
||||
def test_generate_report_creates_and_returns_report(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
|
||||
resp = client.post(f"/api/v1/companies/{company['id']}/reports/generate", headers=headers)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["report_type"] == "manual"
|
||||
assert body["model_provider"] == "mock"
|
||||
assert company["name"] in body["executive_summary"]
|
||||
|
||||
|
||||
def test_list_reports_and_get_detail(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
created = client.post(
|
||||
f"/api/v1/companies/{company['id']}/reports/generate", headers=headers
|
||||
).json()
|
||||
|
||||
listing = client.get(f"/api/v1/companies/{company['id']}/reports", headers=headers).json()
|
||||
assert any(r["id"] == created["id"] for r in listing)
|
||||
|
||||
detail = client.get(f"/api/v1/reports/{created['id']}", headers=headers)
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["structured_report"]["executive_summary"]
|
||||
|
||||
|
||||
def test_report_markdown_and_json_export(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
created = client.post(
|
||||
f"/api/v1/companies/{company['id']}/reports/generate", headers=headers
|
||||
).json()
|
||||
|
||||
md_resp = client.get(f"/api/v1/reports/{created['id']}/markdown", headers=headers)
|
||||
assert md_resp.status_code == 200
|
||||
assert md_resp.headers["content-type"].startswith("text/markdown")
|
||||
assert "# Competitive Intelligence Report" in md_resp.text
|
||||
|
||||
json_resp = client.get(f"/api/v1/reports/{created['id']}/json", headers=headers)
|
||||
assert json_resp.status_code == 200
|
||||
assert "executive_summary" in json_resp.json()
|
||||
|
||||
|
||||
def test_reports_scoped_to_owner(client):
|
||||
owner_headers = _register_and_login(client)
|
||||
other_headers = _register_and_login(client)
|
||||
company = _create_company(client, owner_headers)
|
||||
created = client.post(
|
||||
f"/api/v1/companies/{company['id']}/reports/generate", headers=owner_headers
|
||||
).json()
|
||||
|
||||
assert (
|
||||
client.get(f"/api/v1/companies/{company['id']}/reports", headers=other_headers).status_code
|
||||
== 404
|
||||
)
|
||||
assert client.get(f"/api/v1/reports/{created['id']}", headers=other_headers).status_code == 404
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/v1/companies/{company['id']}/reports/generate", headers=other_headers
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for the documented significance/confidence/severity formula in
|
||||
app/change_detection/scoring.py (see ARCHITECTURE.md)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.change_detection.scoring import classify_severity, compute_confidence, compute_significance
|
||||
from app.models.enums import ChangeType, SeverityLevel
|
||||
|
||||
|
||||
def test_significance_scales_with_source_trust():
|
||||
high_trust = compute_significance(
|
||||
change_type=ChangeType.NEW_DOCUMENT, source_trust_score=1.0, independent_source_count=2
|
||||
)
|
||||
low_trust = compute_significance(
|
||||
change_type=ChangeType.NEW_DOCUMENT, source_trust_score=0.3, independent_source_count=2
|
||||
)
|
||||
assert high_trust > low_trust
|
||||
|
||||
|
||||
def test_significance_uncorroborated_signal_is_halved():
|
||||
corroborated = compute_significance(
|
||||
change_type=ChangeType.NEW_DOCUMENT, source_trust_score=1.0, independent_source_count=2
|
||||
)
|
||||
single_source = compute_significance(
|
||||
change_type=ChangeType.NEW_DOCUMENT, source_trust_score=1.0, independent_source_count=1
|
||||
)
|
||||
assert single_source == round(corroborated / 2, 4)
|
||||
|
||||
|
||||
def test_significance_focus_match_boosts_score():
|
||||
matched = compute_significance(
|
||||
change_type=ChangeType.NEW_DOCUMENT,
|
||||
source_trust_score=1.0,
|
||||
independent_source_count=2,
|
||||
focus_match=True,
|
||||
)
|
||||
unmatched = compute_significance(
|
||||
change_type=ChangeType.NEW_DOCUMENT,
|
||||
source_trust_score=1.0,
|
||||
independent_source_count=2,
|
||||
focus_match=False,
|
||||
)
|
||||
assert matched > unmatched
|
||||
|
||||
|
||||
def test_significance_repeat_change_is_dampened():
|
||||
fresh = compute_significance(
|
||||
change_type=ChangeType.NEW_DOCUMENT, source_trust_score=1.0, is_repeat=False
|
||||
)
|
||||
repeat = compute_significance(
|
||||
change_type=ChangeType.NEW_DOCUMENT, source_trust_score=1.0, is_repeat=True
|
||||
)
|
||||
assert repeat == round(fresh / 2, 4)
|
||||
|
||||
|
||||
def test_significance_content_modified_scales_with_diff_ratio():
|
||||
small_edit = compute_significance(
|
||||
change_type=ChangeType.CONTENT_MODIFIED,
|
||||
source_trust_score=1.0,
|
||||
independent_source_count=2,
|
||||
diff_ratio=0.05,
|
||||
)
|
||||
big_rewrite = compute_significance(
|
||||
change_type=ChangeType.CONTENT_MODIFIED,
|
||||
source_trust_score=1.0,
|
||||
independent_source_count=2,
|
||||
diff_ratio=0.9,
|
||||
)
|
||||
assert big_rewrite > small_edit
|
||||
|
||||
|
||||
def test_significance_never_exceeds_one():
|
||||
value = compute_significance(
|
||||
change_type=ChangeType.LEADERSHIP_CHANGE,
|
||||
source_trust_score=1.0,
|
||||
independent_source_count=10,
|
||||
focus_match=True,
|
||||
)
|
||||
assert value <= 1.0
|
||||
|
||||
|
||||
def test_confidence_increases_with_corroboration():
|
||||
single = compute_confidence(
|
||||
extraction_confidence=0.8, source_trust_score=0.8, independent_source_count=1
|
||||
)
|
||||
corroborated = compute_confidence(
|
||||
extraction_confidence=0.8, source_trust_score=0.8, independent_source_count=2
|
||||
)
|
||||
assert corroborated > single
|
||||
|
||||
|
||||
def test_confidence_bounded_between_zero_and_one():
|
||||
assert (
|
||||
0.0
|
||||
<= compute_confidence(
|
||||
extraction_confidence=0.0, source_trust_score=0.0, independent_source_count=1
|
||||
)
|
||||
<= 1.0
|
||||
)
|
||||
assert (
|
||||
0.0
|
||||
<= compute_confidence(
|
||||
extraction_confidence=1.0, source_trust_score=1.0, independent_source_count=5
|
||||
)
|
||||
<= 1.0
|
||||
)
|
||||
|
||||
|
||||
def test_classify_severity_buckets_by_score():
|
||||
assert classify_severity(significance=0.9, confidence=0.9) == SeverityLevel.CRITICAL
|
||||
assert classify_severity(significance=0.6, confidence=0.9) == SeverityLevel.HIGH
|
||||
assert classify_severity(significance=0.3, confidence=0.9) == SeverityLevel.MEDIUM
|
||||
assert classify_severity(significance=0.1, confidence=0.9) == SeverityLevel.LOW
|
||||
|
||||
|
||||
def test_classify_severity_critical_requires_high_confidence():
|
||||
"""A score that would otherwise land in the Critical bucket (>= 0.6)
|
||||
must be downgraded to High when confidence is below the floor - an
|
||||
uncorroborated single-source signal can't carry the Critical label."""
|
||||
score = 1.0 * 0.65
|
||||
assert score >= 0.6 # would be CRITICAL by score alone
|
||||
severity = classify_severity(significance=1.0, confidence=0.65)
|
||||
assert severity == SeverityLevel.HIGH
|
||||
|
||||
|
||||
def test_classify_severity_high_confidence_allows_critical():
|
||||
score = 1.0 * 0.8
|
||||
assert score >= 0.6
|
||||
severity = classify_severity(significance=1.0, confidence=0.8)
|
||||
assert severity == SeverityLevel.CRITICAL
|
||||
@@ -0,0 +1,78 @@
|
||||
"""SearchProvider: Mock (deterministic, honest about having no real
|
||||
evidence) and Brave (respx-mocked REST call), plus the factory's routing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.search.factory import get_search_provider
|
||||
from app.search.mock import MockSearchProvider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_provider_guesses_a_domain_for_official_website_queries():
|
||||
provider = MockSearchProvider()
|
||||
results = await provider.search("Acme Mobility official website")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].url == "https://acmemobility.com"
|
||||
assert "Acme Mobility" in results[0].title
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_provider_is_honest_about_no_evidence_for_other_queries():
|
||||
provider = MockSearchProvider()
|
||||
results = await provider.search("Acme Mobility competitors")
|
||||
|
||||
assert len(results) == 1
|
||||
assert "no information available" in results[0].snippet.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_provider_respects_count():
|
||||
provider = MockSearchProvider()
|
||||
results = await provider.search("Acme official website", count=0)
|
||||
assert results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brave_provider_maps_api_response_to_search_results():
|
||||
from app.search.brave import BraveSearchProvider
|
||||
|
||||
settings = Settings(search_provider="brave", brave_search_api_key="test-key")
|
||||
provider = BraveSearchProvider(settings)
|
||||
|
||||
with respx.mock:
|
||||
route = respx.get("https://api.search.brave.com/res/v1/web/search").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"web": {
|
||||
"results": [
|
||||
{
|
||||
"title": "Acme Mobility - Official Site",
|
||||
"url": "https://acmemobility.com",
|
||||
"description": "Acme Mobility builds electric scooters.",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
results = await provider.search("Acme Mobility official website", count=3)
|
||||
|
||||
assert route.calls.last.request.headers["X-Subscription-Token"] == "test-key"
|
||||
assert len(results) == 1
|
||||
assert results[0].url == "https://acmemobility.com"
|
||||
assert results[0].snippet == "Acme Mobility builds electric scooters."
|
||||
|
||||
|
||||
def test_factory_routes_by_search_provider_setting():
|
||||
mock_provider = get_search_provider(Settings(search_provider="mock"))
|
||||
assert mock_provider.provider_name == "mock"
|
||||
|
||||
brave_provider = get_search_provider(Settings(search_provider="brave"))
|
||||
assert brave_provider.provider_name == "brave"
|
||||
@@ -0,0 +1,654 @@
|
||||
"""End-to-end HTTP + service-level tests for Phase 19: email verification,
|
||||
password reset, account lockout, and Turnstile enforcement.
|
||||
|
||||
Each test that exercises the IP-throttle-gated endpoints uses its own
|
||||
synthetic client IP (`_unique_ip` + a fresh `TestClient(app, client=(ip,
|
||||
...))`), rather than the shared `client` fixture's fake "testclient" peer -
|
||||
sharing that IP across tests previously caused a real bug (register()
|
||||
exhausting the resend-verification ladder and permanently banning
|
||||
"testclient", see auth_service.register's docstring) and every test here
|
||||
would risk reintroducing the same class of collision if it shared IPs.
|
||||
|
||||
The login-lockout ladder is walked at the service level (like
|
||||
test_ip_throttle_service.py) with `ip_throttle_service._now` monkeypatched
|
||||
forward, so 12 escalating attempts take milliseconds of real test time
|
||||
instead of ~30 real minutes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import AuthenticationError
|
||||
from app.db.base import ensure_aware_utc
|
||||
from app.main import app
|
||||
from app.models.enums import ThrottleAction
|
||||
from app.notifications.base import DeliveryResult
|
||||
from app.repositories.ip_throttle_repository import IpThrottleRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.schemas.auth import LoginRequest, RegisterRequest
|
||||
from app.services import auth_service, ip_throttle_service
|
||||
from app.services.turnstile_service import verify_turnstile
|
||||
|
||||
|
||||
def _unique_email() -> str:
|
||||
return f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
|
||||
|
||||
def _unique_ip() -> str:
|
||||
# Randomize all three trailing octets (same convention as
|
||||
# test_ip_throttle_service.py) - a single-octet range only has ~250
|
||||
# values, which collides often enough across a full suite run (birthday
|
||||
# paradox) to cause real, intermittent failures between unrelated tests
|
||||
# that happen to share ip_throttle_state rows.
|
||||
return f"10.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}"
|
||||
|
||||
|
||||
def _install_fake_smtp(monkeypatch) -> list[dict]:
|
||||
sent: list[dict] = []
|
||||
|
||||
class FakeSmtp:
|
||||
def __init__(self, host, port, timeout=10):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
pass
|
||||
|
||||
def login(self, username, password):
|
||||
pass
|
||||
|
||||
def sendmail(self, from_addr, to_addrs, message):
|
||||
sent.append({"to": to_addrs, "message": message})
|
||||
|
||||
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
|
||||
return sent
|
||||
|
||||
|
||||
def _extract_code(sent_emails: list[dict]) -> str:
|
||||
message = sent_emails[-1]["message"]
|
||||
match = re.search(r"code is[^\d]*(\d{6})", message)
|
||||
assert match, f"no 6-digit code found in most recent sent email: {message!r}"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
# --- Email verification --------------------------------------------------
|
||||
|
||||
|
||||
def test_verify_email_wrong_code_is_generic_failure(client: TestClient):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/verify-email", json={"email": _unique_email(), "code": "000000"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_verify_email_code_guessing_throttled_after_five_attempts(monkeypatch):
|
||||
"""A 6-digit code has only 1M possible values - without this, an
|
||||
attacker could brute-force it well within its 36h validity window."""
|
||||
_install_fake_smtp(monkeypatch)
|
||||
ip = _unique_ip()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
email = _unique_email()
|
||||
c.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
for _ in range(5):
|
||||
resp = c.post("/api/v1/auth/verify-email", json={"email": email, "code": "000000"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
throttled = c.post("/api/v1/auth/verify-email", json={"email": email, "code": "000000"})
|
||||
assert throttled.status_code == 429
|
||||
# See the resend-verification test above for why this tolerates a
|
||||
# 1-second real-clock rounding jitter.
|
||||
assert throttled.json()["retry_after_seconds"] in (4, 5)
|
||||
|
||||
|
||||
def test_confirm_password_reset_code_guessing_throttled_after_five_attempts(monkeypatch):
|
||||
_install_fake_smtp(monkeypatch)
|
||||
ip = _unique_ip()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
email = _unique_email()
|
||||
c.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
c.post("/api/v1/auth/request-password-reset", json={"email": email})
|
||||
|
||||
for _ in range(5):
|
||||
resp = c.post(
|
||||
"/api/v1/auth/confirm-password-reset",
|
||||
json={"email": email, "code": "000000", "new_password": "new-horse-2"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
throttled = c.post(
|
||||
"/api/v1/auth/confirm-password-reset",
|
||||
json={"email": email, "code": "000000", "new_password": "new-horse-2"},
|
||||
)
|
||||
assert throttled.status_code == 429
|
||||
assert throttled.json()["retry_after_seconds"] in (4, 5)
|
||||
|
||||
|
||||
async def test_login_blocked_until_verified_then_succeeds_after_verify_email(monkeypatch):
|
||||
sent = _install_fake_smtp(monkeypatch)
|
||||
ip = _unique_ip()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
email = _unique_email()
|
||||
c.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
# Test env auto-verifies on register (no real inbox to read from) -
|
||||
# flip it back off directly to exercise the actual gate.
|
||||
from app.db.session import get_sessionmaker
|
||||
|
||||
session_factory = get_sessionmaker()
|
||||
async with session_factory() as db:
|
||||
user = await UserRepository(db).get_by_email(email)
|
||||
user.email_verified = False
|
||||
await db.commit()
|
||||
|
||||
blocked = c.post("/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"})
|
||||
assert blocked.status_code == 401
|
||||
assert "verify" in blocked.json()["detail"].lower()
|
||||
|
||||
code = _extract_code(sent)
|
||||
verify_resp = c.post("/api/v1/auth/verify-email", json={"email": email, "code": code})
|
||||
assert verify_resp.status_code == 204
|
||||
|
||||
allowed = c.post("/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"})
|
||||
assert allowed.status_code == 200
|
||||
|
||||
|
||||
async def test_verify_email_old_code_invalidated_by_resend(monkeypatch):
|
||||
"""A resend must fully supersede the prior code, not just make it
|
||||
harder to guess - the old one must stop working entirely."""
|
||||
sent = _install_fake_smtp(monkeypatch)
|
||||
ip = _unique_ip()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
email = _unique_email()
|
||||
c.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
old_code = _extract_code(sent)
|
||||
|
||||
# Test env auto-verifies on register, which would make resend() a
|
||||
# no-op (it only resends for a not-yet-verified account) - flip it
|
||||
# back off directly so a genuine second code actually gets issued.
|
||||
from app.db.session import get_sessionmaker
|
||||
|
||||
session_factory = get_sessionmaker()
|
||||
async with session_factory() as db:
|
||||
user = await UserRepository(db).get_by_email(email)
|
||||
user.email_verified = False
|
||||
await db.commit()
|
||||
|
||||
c.post("/api/v1/auth/resend-verification", json={"email": email})
|
||||
new_code = _extract_code(sent)
|
||||
|
||||
stale = c.post("/api/v1/auth/verify-email", json={"email": email, "code": old_code})
|
||||
assert stale.status_code == 401
|
||||
|
||||
fresh = c.post("/api/v1/auth/verify-email", json={"email": email, "code": new_code})
|
||||
assert fresh.status_code == 204
|
||||
|
||||
|
||||
def test_resend_verification_throttled_immediately_after_first_click(monkeypatch):
|
||||
_install_fake_smtp(monkeypatch)
|
||||
ip = _unique_ip()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
email = _unique_email()
|
||||
c.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
first = c.post("/api/v1/auth/resend-verification", json={"email": email})
|
||||
assert first.status_code == 204
|
||||
|
||||
second = c.post("/api/v1/auth/resend-verification", json={"email": email})
|
||||
assert second.status_code == 429
|
||||
# retry_after_seconds is int((next_allowed_at - now).total_seconds()),
|
||||
# computed against the real clock here - a few ms of real elapsed
|
||||
# time between the two HTTP calls can round it down from 30 to 29.
|
||||
retry_after = second.json()["retry_after_seconds"]
|
||||
assert retry_after in (29, 30)
|
||||
assert second.headers["retry-after"] == str(retry_after)
|
||||
|
||||
|
||||
def test_resend_verification_unknown_email_is_generic_success(monkeypatch):
|
||||
sent = _install_fake_smtp(monkeypatch)
|
||||
ip = _unique_ip()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
resp = c.post("/api/v1/auth/resend-verification", json={"email": _unique_email()})
|
||||
assert resp.status_code == 204
|
||||
assert sent == [] # no account -> nothing actually sent, but no enumeration signal either
|
||||
|
||||
|
||||
# --- Password reset --------------------------------------------------------
|
||||
|
||||
|
||||
def test_password_reset_old_code_invalidated_by_new_request(monkeypatch):
|
||||
sent = _install_fake_smtp(monkeypatch)
|
||||
email = _unique_email()
|
||||
ip1, ip2 = _unique_ip(), _unique_ip()
|
||||
|
||||
with TestClient(app, client=(ip1, 51234)) as c1:
|
||||
c1.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
c1.post("/api/v1/auth/request-password-reset", json={"email": email})
|
||||
old_code = _extract_code(sent)
|
||||
|
||||
with TestClient(app, client=(ip2, 51235)) as c2:
|
||||
c2.post("/api/v1/auth/request-password-reset", json={"email": email})
|
||||
new_code = _extract_code(sent)
|
||||
|
||||
stale = c2.post(
|
||||
"/api/v1/auth/confirm-password-reset",
|
||||
json={"email": email, "code": old_code, "new_password": "new-horse-2"},
|
||||
)
|
||||
assert stale.status_code == 401
|
||||
|
||||
fresh = c2.post(
|
||||
"/api/v1/auth/confirm-password-reset",
|
||||
json={"email": email, "code": new_code, "new_password": "new-horse-2"},
|
||||
)
|
||||
assert fresh.status_code == 204
|
||||
|
||||
|
||||
def test_password_reset_rejects_reusing_current_password(monkeypatch):
|
||||
sent = _install_fake_smtp(monkeypatch)
|
||||
ip = _unique_ip()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
email = _unique_email()
|
||||
c.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
c.post("/api/v1/auth/request-password-reset", json={"email": email})
|
||||
code = _extract_code(sent)
|
||||
|
||||
rejected = c.post(
|
||||
"/api/v1/auth/confirm-password-reset",
|
||||
json={"email": email, "code": code, "new_password": "correct-horse-1"},
|
||||
)
|
||||
assert rejected.status_code == 400
|
||||
assert "used this password before" in rejected.json()["detail"].lower()
|
||||
|
||||
# Rejection must not consume the code - it still works with a
|
||||
# genuinely different password right after.
|
||||
retry = c.post(
|
||||
"/api/v1/auth/confirm-password-reset",
|
||||
json={"email": email, "code": code, "new_password": "different-horse-9"},
|
||||
)
|
||||
assert retry.status_code == 204
|
||||
|
||||
|
||||
def test_password_reset_rejects_reusing_a_previous_not_just_current_password(monkeypatch):
|
||||
sent = _install_fake_smtp(monkeypatch)
|
||||
email = _unique_email()
|
||||
ip1, ip2 = _unique_ip(), _unique_ip()
|
||||
|
||||
with TestClient(app, client=(ip1, 51234)) as c1:
|
||||
c1.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
c1.post("/api/v1/auth/request-password-reset", json={"email": email})
|
||||
code1 = _extract_code(sent)
|
||||
first = c1.post(
|
||||
"/api/v1/auth/confirm-password-reset",
|
||||
json={"email": email, "code": code1, "new_password": "new-horse-2"},
|
||||
)
|
||||
assert first.status_code == 204
|
||||
|
||||
with TestClient(app, client=(ip2, 51235)) as c2:
|
||||
c2.post("/api/v1/auth/request-password-reset", json={"email": email})
|
||||
code2 = _extract_code(sent)
|
||||
|
||||
# correct-horse-1 is no longer the current password, but it's still
|
||||
# in this account's history - must still be rejected.
|
||||
rejected = c2.post(
|
||||
"/api/v1/auth/confirm-password-reset",
|
||||
json={"email": email, "code": code2, "new_password": "correct-horse-1"},
|
||||
)
|
||||
assert rejected.status_code == 400
|
||||
|
||||
accepted = c2.post(
|
||||
"/api/v1/auth/confirm-password-reset",
|
||||
json={"email": email, "code": code2, "new_password": "third-horse-3"},
|
||||
)
|
||||
assert accepted.status_code == 204
|
||||
|
||||
|
||||
def test_password_reset_full_round_trip_then_old_sessions_revoked(monkeypatch):
|
||||
sent = _install_fake_smtp(monkeypatch)
|
||||
ip = _unique_ip()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
email = _unique_email()
|
||||
c.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
tokens = c.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
|
||||
reset_req = c.post("/api/v1/auth/request-password-reset", json={"email": email})
|
||||
assert reset_req.status_code == 204
|
||||
|
||||
code = _extract_code(sent)
|
||||
confirm = c.post(
|
||||
"/api/v1/auth/confirm-password-reset",
|
||||
json={"email": email, "code": code, "new_password": "new-horse-2"},
|
||||
)
|
||||
assert confirm.status_code == 204
|
||||
|
||||
old_password_login = c.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
)
|
||||
assert old_password_login.status_code == 401
|
||||
|
||||
new_password_login = c.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "new-horse-2"}
|
||||
)
|
||||
assert new_password_login.status_code == 200
|
||||
|
||||
# A reset invalidates sessions that existed before it.
|
||||
stale_refresh = c.post(
|
||||
"/api/v1/auth/refresh", json={"refresh_token": tokens["refresh_token"]}
|
||||
)
|
||||
assert stale_refresh.status_code == 401
|
||||
|
||||
|
||||
def test_request_password_reset_unknown_email_is_generic_success(monkeypatch):
|
||||
sent = _install_fake_smtp(monkeypatch)
|
||||
ip = _unique_ip()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
resp = c.post("/api/v1/auth/request-password-reset", json={"email": _unique_email()})
|
||||
assert resp.status_code == 204
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_confirm_password_reset_wrong_code_is_generic_failure(client: TestClient):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/confirm-password-reset",
|
||||
json={"email": _unique_email(), "code": "000000", "new_password": "new-horse-2"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# --- Account lockout (service-level, clock-walked) -------------------------
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
def __call__(self) -> datetime:
|
||||
return self.now
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.now += timedelta(seconds=seconds)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def clock(monkeypatch):
|
||||
fake_clock = _Clock()
|
||||
monkeypatch.setattr(ip_throttle_service, "_now", fake_clock)
|
||||
return fake_clock
|
||||
|
||||
|
||||
async def _advance_past_login_throttle(db_session, clock: _Clock, ip: str) -> None:
|
||||
peeked = await ip_throttle_service.peek_throttle(db_session, ip, ThrottleAction.FAILED_LOGIN)
|
||||
if not peeked.allowed and peeked.retry_after_seconds:
|
||||
clock.advance(peeked.retry_after_seconds)
|
||||
elif not peeked.allowed:
|
||||
# Banned outright (no retry_after) - shouldn't happen within this
|
||||
# test's 12-attempt ladder, but fail loudly if it ever does.
|
||||
state = await IpThrottleRepository(db_session).get_state(ip, ThrottleAction.FAILED_LOGIN)
|
||||
if state and state.timeout_until is not None:
|
||||
clock.advance((ensure_aware_utc(state.timeout_until) - clock.now).total_seconds() + 1)
|
||||
|
||||
|
||||
async def test_login_lockout_after_twelve_failed_attempts_locks_account_and_notifies(
|
||||
db_session, clock, monkeypatch
|
||||
):
|
||||
settings = get_settings()
|
||||
ip = _unique_ip()
|
||||
email = _unique_email()
|
||||
|
||||
user = await auth_service.register(
|
||||
db_session,
|
||||
settings,
|
||||
ip,
|
||||
RegisterRequest(email=email, password="correct-horse-1", display_name="T"),
|
||||
)
|
||||
assert user.email_verified is True # app_env == "test" auto-verify precedent
|
||||
|
||||
locked_emails: list[str] = []
|
||||
|
||||
async def fake_send_locked(_settings, to):
|
||||
locked_emails.append(to)
|
||||
return DeliveryResult(success=True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_service.security_email_service, "send_account_locked_email", fake_send_locked
|
||||
)
|
||||
|
||||
wrong_login = LoginRequest(email=email, password="wrong-password-1")
|
||||
for _ in range(12):
|
||||
await _advance_past_login_throttle(db_session, clock, ip)
|
||||
with pytest.raises(AuthenticationError):
|
||||
await auth_service.login(db_session, settings, ip, wrong_login)
|
||||
|
||||
refreshed = await UserRepository(db_session).get_by_id(user.id)
|
||||
assert refreshed.failed_login_count == 12
|
||||
assert refreshed.locked_at is not None
|
||||
assert locked_emails == [email]
|
||||
|
||||
# The account stays locked even with the *correct* password, and even
|
||||
# once the IP itself is no longer throttled.
|
||||
await _advance_past_login_throttle(db_session, clock, ip)
|
||||
correct_login = LoginRequest(email=email, password="correct-horse-1")
|
||||
with pytest.raises(AuthenticationError, match="locked"):
|
||||
await auth_service.login(db_session, settings, ip, correct_login)
|
||||
|
||||
|
||||
async def test_login_correct_password_resets_failed_count_before_lockout(db_session, clock):
|
||||
settings = get_settings()
|
||||
ip = _unique_ip()
|
||||
email = _unique_email()
|
||||
|
||||
user = await auth_service.register(
|
||||
db_session,
|
||||
settings,
|
||||
ip,
|
||||
RegisterRequest(email=email, password="correct-horse-1", display_name="T"),
|
||||
)
|
||||
|
||||
wrong_login = LoginRequest(email=email, password="wrong-password-1")
|
||||
for _ in range(3):
|
||||
await _advance_past_login_throttle(db_session, clock, ip)
|
||||
with pytest.raises(AuthenticationError):
|
||||
await auth_service.login(db_session, settings, ip, wrong_login)
|
||||
|
||||
await _advance_past_login_throttle(db_session, clock, ip)
|
||||
correct_login = LoginRequest(email=email, password="correct-horse-1")
|
||||
await auth_service.login(db_session, settings, ip, correct_login)
|
||||
|
||||
refreshed = await UserRepository(db_session).get_by_id(user.id)
|
||||
assert refreshed.failed_login_count == 0
|
||||
assert refreshed.locked_at is None
|
||||
|
||||
|
||||
# --- Turnstile ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_register_without_turnstile_token_rejected_when_configured(client: TestClient):
|
||||
settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"})
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": _unique_email(), "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_settings, None)
|
||||
|
||||
|
||||
def test_register_with_verified_turnstile_token_succeeds(client: TestClient, monkeypatch):
|
||||
settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"})
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
|
||||
async def fake_verify(token, remote_ip, _settings):
|
||||
assert token == "good-token"
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.v1.auth.verify_turnstile", fake_verify)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": _unique_email(),
|
||||
"password": "correct-horse-1",
|
||||
"display_name": "T",
|
||||
"turnstile_token": "good-token",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_settings, None)
|
||||
|
||||
|
||||
def test_register_skips_turnstile_entirely_on_localhost_even_when_configured():
|
||||
settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"})
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
try:
|
||||
with TestClient(app, client=("127.0.0.1", 54321)) as loopback_client:
|
||||
resp = loopback_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": _unique_email(),
|
||||
"password": "correct-horse-1",
|
||||
"display_name": "T",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_settings, None)
|
||||
|
||||
|
||||
async def test_register_succeeds_for_non_localhost_caller_when_configured_secret_is_invalid(
|
||||
client: TestClient,
|
||||
):
|
||||
"""End-to-end proof (not just verify_turnstile in isolation): a
|
||||
non-loopback caller can still register when the admin's configured
|
||||
secret is itself broken, regardless of what token they submitted."""
|
||||
settings = get_settings().model_copy(update={"turnstile_secret": "a-typo-d-secret"})
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
try:
|
||||
with respx.mock:
|
||||
respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"success": False, "error-codes": ["invalid-input-secret"]}
|
||||
)
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": _unique_email(),
|
||||
"password": "correct-horse-1",
|
||||
"display_name": "T",
|
||||
"turnstile_token": "whatever-token",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_settings, None)
|
||||
|
||||
|
||||
async def test_verify_turnstile_returns_true_on_cloudflare_success():
|
||||
settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"})
|
||||
with respx.mock:
|
||||
respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock(
|
||||
return_value=httpx.Response(200, json={"success": True})
|
||||
)
|
||||
result = await verify_turnstile("some-token", "1.2.3.4", settings)
|
||||
assert result is True
|
||||
|
||||
|
||||
async def test_verify_turnstile_fails_closed_on_network_error():
|
||||
settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"})
|
||||
with respx.mock:
|
||||
respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock(
|
||||
side_effect=httpx.ConnectError("boom")
|
||||
)
|
||||
result = await verify_turnstile("some-token", "1.2.3.4", settings)
|
||||
assert result is False
|
||||
|
||||
|
||||
async def test_verify_turnstile_fails_open_when_the_configured_secret_itself_is_invalid():
|
||||
"""A typo'd/invalid secret is a detectable config problem (Cloudflare
|
||||
reports it via error-codes), not an ambiguous failure - locking out
|
||||
every real visitor over an admin's own mistake is worse than briefly
|
||||
running with reduced bot protection."""
|
||||
settings = get_settings().model_copy(update={"turnstile_secret": "a-typo-d-secret"})
|
||||
with respx.mock:
|
||||
respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"success": False, "error-codes": ["invalid-input-secret"]}
|
||||
)
|
||||
)
|
||||
result = await verify_turnstile("some-token", "1.2.3.4", settings)
|
||||
assert result is True
|
||||
|
||||
|
||||
async def test_verify_turnstile_still_fails_closed_for_a_genuinely_bad_user_token():
|
||||
"""The fail-open carve-out is scoped to secret-level error codes only -
|
||||
a real rejection of the user's own token must still fail closed."""
|
||||
settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"})
|
||||
with respx.mock:
|
||||
respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"success": False, "error-codes": ["invalid-input-response"]}
|
||||
)
|
||||
)
|
||||
result = await verify_turnstile("bad-token", "1.2.3.4", settings)
|
||||
assert result is False
|
||||
|
||||
|
||||
# --- IP ban -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_register_rejected_when_ip_is_banned(db_session):
|
||||
"""A ban is IP-global - it must block brand-new account creation too,
|
||||
not just actions against existing accounts (login/resend/reset)."""
|
||||
ip = _unique_ip()
|
||||
await IpThrottleRepository(db_session).create_ban(ip, "failed_login", datetime.now(UTC))
|
||||
await db_session.commit()
|
||||
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
resp = c.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": _unique_email(), "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
assert resp.json()["detail"] == "This IP address has been temporarily blocked."
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Snapshots API: read-only history listing, newest-first, ownership-scoped.
|
||||
Snapshots have no creation endpoint (they're written internally by
|
||||
collection_service.py during a monitoring run), so tests insert one directly
|
||||
via db_session against the same DB the `client` fixture's TestClient uses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.snapshot import Snapshot
|
||||
|
||||
|
||||
def _register_and_login(client) -> dict[str, str]:
|
||||
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def _create_company(client, headers):
|
||||
return client.post(
|
||||
"/api/v1/companies",
|
||||
json={"name": f"Co {uuid.uuid4().hex[:6]}", "frequency_type": "weekly"},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
|
||||
def _create_source(client, headers, company_id):
|
||||
return client.post(
|
||||
f"/api/v1/companies/{company_id}/sources",
|
||||
json={"source_type": "custom_url", "name": "Pricing", "base_url": "https://example.com"},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
|
||||
def test_snapshots_empty_before_any_collection(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
|
||||
resp = client.get(f"/api/v1/companies/{company['id']}/snapshots", headers=headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshots_list_newest_first(client, db_session):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
source = _create_source(client, headers, company["id"])
|
||||
|
||||
older = Snapshot(
|
||||
company_id=uuid.UUID(company["id"]),
|
||||
source_id=uuid.UUID(source["id"]),
|
||||
snapshot_type="website",
|
||||
hash="hash-older",
|
||||
structured_summary={"title": "Old"},
|
||||
text_summary="Old page text",
|
||||
)
|
||||
db_session.add(older)
|
||||
await db_session.commit()
|
||||
|
||||
newer = Snapshot(
|
||||
company_id=uuid.UUID(company["id"]),
|
||||
source_id=uuid.UUID(source["id"]),
|
||||
snapshot_type="website",
|
||||
hash="hash-newer",
|
||||
structured_summary={"title": "New"},
|
||||
text_summary="New page text",
|
||||
)
|
||||
db_session.add(newer)
|
||||
await db_session.commit()
|
||||
|
||||
resp = client.get(f"/api/v1/companies/{company['id']}/snapshots", headers=headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body) == 2
|
||||
assert body[0]["hash"] == "hash-newer"
|
||||
assert body[1]["hash"] == "hash-older"
|
||||
assert body[0]["text_summary"] == "New page text"
|
||||
|
||||
|
||||
def test_snapshots_scoped_to_owner(client):
|
||||
owner_headers = _register_and_login(client)
|
||||
other_headers = _register_and_login(client)
|
||||
company = _create_company(client, owner_headers)
|
||||
|
||||
resp = client.get(f"/api/v1/companies/{company['id']}/snapshots", headers=other_headers)
|
||||
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Sources API: ownership isolation, user-creatable type restriction, and
|
||||
the ad-hoc test action - via HTTP, with respx mocking the network call the
|
||||
test action makes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import respx
|
||||
|
||||
|
||||
def _register_and_login(client) -> dict[str, str]:
|
||||
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def _create_company(client, headers):
|
||||
return client.post(
|
||||
"/api/v1/companies",
|
||||
json={"name": f"Co {uuid.uuid4().hex[:6]}", "frequency_type": "weekly"},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
|
||||
def test_create_custom_url_source(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/companies/{company['id']}/sources",
|
||||
json={"source_type": "custom_url", "name": "Pricing", "base_url": "example.com/pricing"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["base_url"] == "https://example.com/pricing"
|
||||
assert body["status"] == "active"
|
||||
|
||||
|
||||
def test_create_source_rejects_non_user_creatable_type(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/companies/{company['id']}/sources",
|
||||
json={"source_type": "github", "name": "GitHub", "base_url": "https://github.com/acme"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_sources_scoped_to_owner(client):
|
||||
owner_headers = _register_and_login(client)
|
||||
other_headers = _register_and_login(client)
|
||||
company = _create_company(client, owner_headers)
|
||||
|
||||
created = client.post(
|
||||
f"/api/v1/companies/{company['id']}/sources",
|
||||
json={
|
||||
"source_type": "custom_url",
|
||||
"name": "Pricing",
|
||||
"base_url": "https://example.com/pricing",
|
||||
},
|
||||
headers=owner_headers,
|
||||
).json()
|
||||
|
||||
# Another user can't list this company's sources...
|
||||
resp = client.get(f"/api/v1/companies/{company['id']}/sources", headers=other_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
# ...or update/delete the source directly.
|
||||
resp = client.patch(
|
||||
f"/api/v1/sources/{created['id']}", json={"active": False}, headers=other_headers
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_update_and_delete_source(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
created = client.post(
|
||||
f"/api/v1/companies/{company['id']}/sources",
|
||||
json={
|
||||
"source_type": "custom_url",
|
||||
"name": "Pricing",
|
||||
"base_url": "https://example.com/pricing",
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
resp = client.patch(f"/api/v1/sources/{created['id']}", json={"active": False}, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["active"] is False
|
||||
|
||||
resp = client.delete(f"/api/v1/sources/{created['id']}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
resp = client.get(f"/api/v1/companies/{company['id']}/sources", headers=headers)
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
def test_update_source_sets_a_frequency_override(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
created = client.post(
|
||||
f"/api/v1/companies/{company['id']}/sources",
|
||||
json={
|
||||
"source_type": "custom_url",
|
||||
"name": "Pricing",
|
||||
"base_url": "https://example.com/pricing",
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/sources/{created['id']}", json={"frequency_type": "daily"}, headers=headers
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["frequency_type"] == "daily"
|
||||
assert body["next_check"] is None # takes effect on the next scheduler tick
|
||||
|
||||
# Clearing the override back to "same as company" is an explicit null.
|
||||
resp = client.patch(
|
||||
f"/api/v1/sources/{created['id']}", json={"frequency_type": None}, headers=headers
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["frequency_type"] is None
|
||||
|
||||
|
||||
def test_update_source_rejects_a_custom_frequency_below_the_minimum_interval(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
created = client.post(
|
||||
f"/api/v1/companies/{company['id']}/sources",
|
||||
json={
|
||||
"source_type": "custom_url",
|
||||
"name": "Pricing",
|
||||
"base_url": "https://example.com/pricing",
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/sources/{created['id']}",
|
||||
json={"frequency_type": "custom", "interval_minutes": 1},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_source_test_action_runs_a_real_collection(client):
|
||||
headers = _register_and_login(client)
|
||||
company = _create_company(client, headers)
|
||||
created = client.post(
|
||||
f"/api/v1/companies/{company['id']}/sources",
|
||||
json={
|
||||
"source_type": "custom_url",
|
||||
"name": "Pricing",
|
||||
"base_url": "https://example.com/pricing",
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/robots.txt").mock(return_value=httpx.Response(404))
|
||||
respx.get("https://example.com/pricing").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
html="<html><head><title>Pricing</title></head><body>"
|
||||
"<article><h1>Pricing</h1><p>Plans start at $10/month.</p></article>"
|
||||
"</body></html>",
|
||||
)
|
||||
)
|
||||
resp = client.post(f"/api/v1/sources/{created['id']}/test", headers=headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "active"
|
||||
assert body["documents_found"] == 1
|
||||
@@ -0,0 +1,77 @@
|
||||
"""SSRF guard tests - see SECURITY.md."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.core.http import SsrfBlockedError, safe_fetch, validate_url
|
||||
|
||||
|
||||
def test_validate_url_rejects_disallowed_scheme():
|
||||
with pytest.raises(SsrfBlockedError):
|
||||
validate_url("file:///etc/passwd")
|
||||
|
||||
|
||||
def test_validate_url_rejects_url_with_no_hostname():
|
||||
with pytest.raises(SsrfBlockedError):
|
||||
validate_url("http://")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hostname,ip",
|
||||
[
|
||||
("localhost-test", "127.0.0.1"),
|
||||
("private-test", "10.0.0.5"),
|
||||
("private-test-2", "192.168.1.1"),
|
||||
("link-local-test", "169.254.1.1"),
|
||||
("metadata-test", "169.254.169.254"),
|
||||
],
|
||||
)
|
||||
def test_validate_url_blocks_private_and_metadata_addresses(hostname, ip):
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", (ip, 0))]):
|
||||
with pytest.raises(SsrfBlockedError):
|
||||
validate_url(f"http://{hostname}/")
|
||||
|
||||
|
||||
def test_validate_url_allows_public_address():
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
validate_url("http://example.com/") # should not raise
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safe_fetch_revalidates_each_redirect_hop():
|
||||
"""A redirect to a private address must be blocked even if the initial
|
||||
URL resolves to a public one."""
|
||||
with patch("socket.getaddrinfo") as mock_resolve:
|
||||
|
||||
def resolve(hostname, *_args, **_kwargs):
|
||||
if hostname == "public.example":
|
||||
return [(2, 1, 6, "", ("93.184.216.34", 0))]
|
||||
if hostname == "internal.example":
|
||||
return [(2, 1, 6, "", ("10.0.0.5", 0))]
|
||||
raise AssertionError(f"unexpected hostname {hostname}")
|
||||
|
||||
mock_resolve.side_effect = resolve
|
||||
|
||||
with respx.mock:
|
||||
respx.get("http://public.example/").mock(
|
||||
return_value=httpx.Response(302, headers={"Location": "http://internal.example/"})
|
||||
)
|
||||
with pytest.raises(SsrfBlockedError):
|
||||
await safe_fetch("http://public.example/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safe_fetch_returns_final_response_body():
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
with respx.mock:
|
||||
respx.get("http://public.example/").mock(
|
||||
return_value=httpx.Response(200, text="hello world")
|
||||
)
|
||||
result = await safe_fetch("http://public.example/")
|
||||
assert result.status_code == 200
|
||||
assert result.text == "hello world"
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.change_detection.structured_diff import diff_item_sets
|
||||
|
||||
|
||||
def test_diff_item_sets_detects_additions_and_removals():
|
||||
previous = ["https://x.com/a", "https://x.com/b"]
|
||||
current = ["https://x.com/b", "https://x.com/c"]
|
||||
diff = diff_item_sets(previous, current)
|
||||
assert diff.added == ["https://x.com/c"]
|
||||
assert diff.removed == ["https://x.com/a"]
|
||||
assert diff.has_changes is True
|
||||
|
||||
|
||||
def test_diff_item_sets_no_change_when_identical():
|
||||
items = ["https://x.com/a", "https://x.com/b"]
|
||||
diff = diff_item_sets(items, list(items))
|
||||
assert diff.added == []
|
||||
assert diff.removed == []
|
||||
assert diff.has_changes is False
|
||||
|
||||
|
||||
def test_diff_item_sets_handles_empty_previous():
|
||||
diff = diff_item_sets([], ["https://x.com/a"])
|
||||
assert diff.added == ["https://x.com/a"]
|
||||
assert diff.removed == []
|
||||
@@ -0,0 +1,121 @@
|
||||
"""/system/status and /system/logs - especially that /system/logs is
|
||||
admin-only (Phase 19 - it exposes operational internals, not something any
|
||||
registered user should read) and that the live log feed actually captures
|
||||
what the app logs. Server-wide secret management (Turnstile site
|
||||
key/secret) moved to /system/secrets - see test_system_secrets.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
from app.main import app
|
||||
from app.repositories.user_repository import UserRepository
|
||||
|
||||
|
||||
def _register_and_login(client: TestClient) -> dict[str, str]:
|
||||
email = f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Test User"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
async def _register_admin_and_login(client: TestClient, db_session: AsyncSession) -> dict[str, str]:
|
||||
"""Registration never accepts is_admin from the client - promote
|
||||
directly in the DB, the same way a real operator would via a one-off
|
||||
script/console, not through any HTTP-exposed path."""
|
||||
email = f"admin-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Admin User"},
|
||||
)
|
||||
user = await UserRepository(db_session).get_by_email(email)
|
||||
user.is_admin = True
|
||||
await db_session.commit()
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def test_system_status_reports_not_localhost_for_default_test_client(client: TestClient):
|
||||
# Starlette's TestClient defaults its ASGI scope client to
|
||||
# ("testclient", 50000), not a loopback address - this is the "someone
|
||||
# not on this machine" case.
|
||||
resp = client.get("/api/v1/system/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_localhost"] is False
|
||||
|
||||
|
||||
def test_system_status_reports_localhost_for_loopback_client():
|
||||
with TestClient(app, client=("127.0.0.1", 54321)) as loopback_client:
|
||||
resp = loopback_client.get("/api/v1/system/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_localhost"] is True
|
||||
|
||||
|
||||
def test_system_status_treats_configured_extra_ip_as_localhost():
|
||||
# Docker Desktop's bridge networking means host-originated traffic
|
||||
# never arrives as literal loopback - additional_trusted_local_ips is
|
||||
# the opt-in escape hatch for that, see app.core.security.is_localhost.
|
||||
test_settings = get_settings().model_copy(
|
||||
update={"additional_trusted_local_ips": "172.18.0.1, 10.0.0.5"}
|
||||
)
|
||||
app.dependency_overrides[get_settings] = lambda: test_settings
|
||||
try:
|
||||
with TestClient(app, client=("172.18.0.1", 54321)) as bridge_client:
|
||||
resp = bridge_client.get("/api/v1/system/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_localhost"] is True
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_settings, None)
|
||||
|
||||
|
||||
def test_system_status_does_not_trust_an_unlisted_ip():
|
||||
test_settings = get_settings().model_copy(update={"additional_trusted_local_ips": "172.18.0.1"})
|
||||
app.dependency_overrides[get_settings] = lambda: test_settings
|
||||
try:
|
||||
with TestClient(app, client=("203.0.113.9", 54321)) as stranger_client:
|
||||
resp = stranger_client.get("/api/v1/system/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_localhost"] is False
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_settings, None)
|
||||
|
||||
|
||||
async def test_system_logs_captures_and_categorizes_real_log_calls(
|
||||
client: TestClient, db_session: AsyncSession
|
||||
):
|
||||
headers = await _register_admin_and_login(client, db_session)
|
||||
marker = f"phase17-test-marker-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger = get_logger("tests.system_logs")
|
||||
logger.warning("test_api_style_failure", event_marker=marker)
|
||||
|
||||
resp = client.get("/api/v1/system/logs", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
entries = resp.json()
|
||||
match = next(e for e in entries if e["context"].get("event_marker") == marker)
|
||||
assert match["category"] == "api_error"
|
||||
assert match["level"] == "warning"
|
||||
assert match["event"] == "test_api_style_failure"
|
||||
|
||||
|
||||
def test_system_logs_requires_auth(client: TestClient):
|
||||
resp = client.get("/api/v1/system/logs")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_system_logs_non_admin_forbidden(client: TestClient):
|
||||
headers = _register_and_login(client)
|
||||
resp = client.get("/api/v1/system/logs", headers=headers)
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Server-wide secrets (Turnstile site key/secret) admin-managed from the
|
||||
Settings page instead of only .env: repository/service behavior, endpoint
|
||||
auth/admin-gating (deliberately NOT localhost-gated, unlike the old
|
||||
/system/api-keys this replaces), /system/status exposing the site key live,
|
||||
and an end-to-end proof that a DB-only (no .env) secret actually drives
|
||||
Turnstile enforcement on register."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.enums import SystemSecretKey
|
||||
from app.repositories.system_secret_repository import SystemSecretRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.services import system_secret_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _clean_system_secrets(db_session):
|
||||
"""SystemSecret rows are true global singletons (one per key, not
|
||||
per-user like UserApiKey) - unlike other tests in this suite that
|
||||
dodge cross-test pollution by randomizing an id, there's no such trick
|
||||
here. Every test starts from a clean slate, and leaves one behind for
|
||||
whatever test file runs next in a full-suite run."""
|
||||
|
||||
async def _clear() -> None:
|
||||
repo = SystemSecretRepository(db_session)
|
||||
for row in await repo.list_all():
|
||||
await db_session.delete(row)
|
||||
await db_session.commit()
|
||||
|
||||
await _clear()
|
||||
yield
|
||||
await _clear()
|
||||
|
||||
|
||||
def _unique_email() -> str:
|
||||
return f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
|
||||
|
||||
def _register_and_login(client: TestClient) -> dict[str, str]:
|
||||
email = _unique_email()
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
async def _register_admin_and_login(client: TestClient, db_session: AsyncSession) -> dict[str, str]:
|
||||
email = f"admin-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Admin"},
|
||||
)
|
||||
user = await UserRepository(db_session).get_by_email(email)
|
||||
user.is_admin = True
|
||||
await db_session.commit()
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
# --- Repository --------------------------------------------------------
|
||||
|
||||
|
||||
async def test_repository_upsert_then_get_then_delete(db_session):
|
||||
repo = SystemSecretRepository(db_session)
|
||||
|
||||
await repo.upsert(SystemSecretKey.TURNSTILE_SECRET, "encrypted-1")
|
||||
row = await repo.get(SystemSecretKey.TURNSTILE_SECRET)
|
||||
assert row is not None
|
||||
assert row.encrypted_value == "encrypted-1"
|
||||
|
||||
await repo.upsert(SystemSecretKey.TURNSTILE_SECRET, "encrypted-2")
|
||||
row = await repo.get(SystemSecretKey.TURNSTILE_SECRET)
|
||||
assert row.encrypted_value == "encrypted-2" # updated in place, not duplicated
|
||||
|
||||
await repo.delete(SystemSecretKey.TURNSTILE_SECRET)
|
||||
assert await repo.get(SystemSecretKey.TURNSTILE_SECRET) is None
|
||||
|
||||
|
||||
# --- Service -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_list_status_shows_both_keys_unconfigured_by_default(db_session):
|
||||
settings = get_settings()
|
||||
statuses = await system_secret_service.list_status(db_session, settings)
|
||||
assert {s["key"] for s in statuses} == {"turnstile_site_key", "turnstile_secret"}
|
||||
assert all(s["configured"] is False for s in statuses)
|
||||
assert all(s["value"] is None for s in statuses)
|
||||
|
||||
|
||||
async def test_set_secret_then_list_status_shows_it_configured(db_session):
|
||||
settings = get_settings()
|
||||
await system_secret_service.set_secret(
|
||||
db_session,
|
||||
SystemSecretKey.TURNSTILE_SITE_KEY,
|
||||
"0x-my-site-key",
|
||||
settings,
|
||||
admin_user_id=uuid.uuid4(),
|
||||
client_ip="10.0.0.1",
|
||||
)
|
||||
statuses = await system_secret_service.list_status(db_session, settings)
|
||||
site_key = next(s for s in statuses if s["key"] == "turnstile_site_key")
|
||||
assert site_key["configured"] is True
|
||||
assert site_key["value"] == "0x-my-site-key"
|
||||
|
||||
|
||||
async def test_set_blank_secret_clears_a_previously_set_one(db_session):
|
||||
settings = get_settings()
|
||||
await system_secret_service.set_secret(
|
||||
db_session,
|
||||
SystemSecretKey.TURNSTILE_SECRET,
|
||||
"0x-my-secret",
|
||||
settings,
|
||||
admin_user_id=uuid.uuid4(),
|
||||
client_ip="10.0.0.1",
|
||||
)
|
||||
await system_secret_service.set_secret(
|
||||
db_session,
|
||||
SystemSecretKey.TURNSTILE_SECRET,
|
||||
" ",
|
||||
settings,
|
||||
admin_user_id=uuid.uuid4(),
|
||||
client_ip="10.0.0.1",
|
||||
)
|
||||
statuses = await system_secret_service.list_status(db_session, settings)
|
||||
secret = next(s for s in statuses if s["key"] == "turnstile_secret")
|
||||
assert secret["configured"] is False
|
||||
assert secret["value"] is None
|
||||
|
||||
|
||||
async def test_get_effective_settings_falls_back_to_global_when_unset(db_session):
|
||||
settings = get_settings().model_copy(update={"turnstile_site_key": "global-site-key"})
|
||||
effective = await system_secret_service.get_effective_settings(db_session, settings)
|
||||
assert effective.turnstile_site_key == "global-site-key"
|
||||
|
||||
|
||||
async def test_get_effective_settings_overrides_only_the_keys_that_were_set(db_session):
|
||||
settings = get_settings().model_copy(
|
||||
update={"turnstile_site_key": "global-site-key", "turnstile_secret": "global-secret"}
|
||||
)
|
||||
await system_secret_service.set_secret(
|
||||
db_session,
|
||||
SystemSecretKey.TURNSTILE_SITE_KEY,
|
||||
"admin-site-key",
|
||||
settings,
|
||||
admin_user_id=uuid.uuid4(),
|
||||
client_ip="10.0.0.1",
|
||||
)
|
||||
effective = await system_secret_service.get_effective_settings(db_session, settings)
|
||||
assert effective.turnstile_site_key == "admin-site-key"
|
||||
assert effective.turnstile_secret == "global-secret" # untouched, no override set
|
||||
|
||||
|
||||
# --- Endpoints -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_system_secrets_requires_auth(client: TestClient):
|
||||
resp = client.get("/api/v1/system/secrets")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_list_system_secrets_non_admin_forbidden(client: TestClient):
|
||||
headers = _register_and_login(client)
|
||||
resp = client.get("/api/v1/system/secrets", headers=headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_can_list_and_set_secrets_even_from_a_non_loopback_client(
|
||||
client: TestClient, db_session: AsyncSession
|
||||
):
|
||||
"""Deliberately different from the old /system/api-keys this replaces:
|
||||
admin-gated only, no additional is_localhost requirement - the default
|
||||
TestClient here has a non-loopback fake peer."""
|
||||
headers = await _register_admin_and_login(client, db_session)
|
||||
|
||||
initial = client.get("/api/v1/system/secrets", headers=headers)
|
||||
assert initial.status_code == 200
|
||||
assert all(not s["configured"] for s in initial.json())
|
||||
|
||||
set_resp = client.put(
|
||||
"/api/v1/system/secrets/turnstile_secret",
|
||||
json={"value": "sk-set-via-api"},
|
||||
headers=headers,
|
||||
)
|
||||
assert set_resp.status_code == 200
|
||||
assert set_resp.json()["configured"] is True
|
||||
assert set_resp.json()["value"] == "sk-set-via-api"
|
||||
|
||||
after = client.get("/api/v1/system/secrets", headers=headers)
|
||||
secret = next(s for s in after.json() if s["key"] == "turnstile_secret")
|
||||
assert secret["configured"] is True
|
||||
assert secret["value"] == "sk-set-via-api"
|
||||
|
||||
|
||||
async def test_updating_a_server_secret_is_logged_to_the_acting_admins_account_activity(
|
||||
client: TestClient, db_session: AsyncSession
|
||||
):
|
||||
headers = await _register_admin_and_login(client, db_session)
|
||||
|
||||
client.put(
|
||||
"/api/v1/system/secrets/turnstile_secret",
|
||||
json={"value": "sk-set-via-api"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
events = client.get("/api/v1/auth/security-events", headers=headers).json()
|
||||
assert any(e["event_type"] == "server_secret_updated" for e in events)
|
||||
|
||||
|
||||
async def test_set_system_secret_rejects_unknown_key(client: TestClient, db_session: AsyncSession):
|
||||
headers = await _register_admin_and_login(client, db_session)
|
||||
resp = client.put("/api/v1/system/secrets/not-a-real-key", json={"value": "x"}, headers=headers)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# --- /system/status exposes the site key live -------------------------
|
||||
|
||||
|
||||
async def test_system_status_exposes_admin_configured_turnstile_site_key(
|
||||
client: TestClient, db_session: AsyncSession
|
||||
):
|
||||
settings = get_settings()
|
||||
await system_secret_service.set_secret(
|
||||
db_session,
|
||||
SystemSecretKey.TURNSTILE_SITE_KEY,
|
||||
"admin-set-site-key",
|
||||
settings,
|
||||
admin_user_id=uuid.uuid4(),
|
||||
client_ip="10.0.0.1",
|
||||
)
|
||||
resp = client.get("/api/v1/system/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["turnstile_site_key"] == "admin-set-site-key"
|
||||
|
||||
|
||||
def test_system_status_turnstile_site_key_is_null_when_unconfigured(client: TestClient):
|
||||
resp = client.get("/api/v1/system/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["turnstile_site_key"] is None
|
||||
|
||||
|
||||
# --- End-to-end: a DB-only secret (no .env value) drives enforcement ----
|
||||
|
||||
|
||||
async def test_register_requires_turnstile_when_only_db_configured_secret_exists(
|
||||
client: TestClient, db_session: AsyncSession
|
||||
):
|
||||
"""Proves _enforce_turnstile actually resolves effective (DB-aware)
|
||||
settings, not just the raw .env-backed global Settings object."""
|
||||
settings = get_settings()
|
||||
assert not settings.turnstile_secret # sanity: nothing set in .env for this test run
|
||||
await system_secret_service.set_secret(
|
||||
db_session,
|
||||
SystemSecretKey.TURNSTILE_SECRET,
|
||||
"admin-set-secret",
|
||||
settings,
|
||||
admin_user_id=uuid.uuid4(),
|
||||
client_ip="10.0.0.1",
|
||||
)
|
||||
|
||||
no_token_resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": _unique_email(), "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
assert no_token_resp.status_code == 400
|
||||
|
||||
with respx.mock:
|
||||
respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock(
|
||||
return_value=httpx.Response(200, json={"success": True})
|
||||
)
|
||||
with_token_resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": _unique_email(),
|
||||
"password": "correct-horse-1",
|
||||
"display_name": "T",
|
||||
"turnstile_token": "good-token",
|
||||
},
|
||||
)
|
||||
assert with_token_resp.status_code == 201
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.change_detection.text_diff import bounded_text_diff
|
||||
|
||||
|
||||
def test_identical_text_has_zero_diff_ratio():
|
||||
text = "Acme Corp builds electric trucks.\nWe are hiring engineers."
|
||||
result = bounded_text_diff(text, text)
|
||||
assert result.diff_ratio == 0.0
|
||||
assert result.is_identical is True
|
||||
|
||||
|
||||
def test_changed_text_has_nonzero_diff_ratio_and_captures_lines():
|
||||
previous = "Acme Corp builds gasoline trucks.\nContact us for a quote."
|
||||
current = "Acme Corp builds electric trucks.\nContact us for a quote."
|
||||
result = bounded_text_diff(previous, current)
|
||||
assert result.diff_ratio > 0.0
|
||||
assert any("electric" in line for line in result.added_lines)
|
||||
assert any("gasoline" in line for line in result.removed_lines)
|
||||
|
||||
|
||||
def test_noise_only_changes_do_not_register_as_a_diff():
|
||||
previous = "About us.\nUpdated: 2026-01-01 10:00\nWe build trucks."
|
||||
current = "About us.\nUpdated: 2026-06-15 14:30\nWe build trucks."
|
||||
result = bounded_text_diff(previous, current)
|
||||
assert result.diff_ratio == 0.0
|
||||
|
||||
|
||||
def test_diff_is_bounded_in_size():
|
||||
previous = "\n".join(f"line {i} original" for i in range(200))
|
||||
current = "\n".join(f"line {i} changed" for i in range(200))
|
||||
result = bounded_text_diff(previous, current)
|
||||
assert len(result.added_lines) <= 40
|
||||
assert len(result.removed_lines) <= 40
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Unban-request intake + admin IP-ban management (Phase 19). No dedicated
|
||||
coverage existed for this endpoint group before - added alongside the
|
||||
Mailpit removal, which changed submit_unban_request to notify every
|
||||
is_admin=True account instead of a single fixed admin_notification_email."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.main import app
|
||||
from app.repositories.ip_throttle_repository import IpThrottleRepository
|
||||
from app.repositories.unban_request_repository import UnbanRequestRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
|
||||
|
||||
def _unique_email() -> str:
|
||||
return f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
|
||||
|
||||
def _unique_ip() -> str:
|
||||
# Randomize all three trailing octets (same convention as
|
||||
# test_ip_throttle_service.py) - a single-octet range only has ~250
|
||||
# values, which collides often enough across a full suite run (birthday
|
||||
# paradox) to cause real, intermittent failures between unrelated tests
|
||||
# that happen to share ip_throttle_state rows.
|
||||
return f"10.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}"
|
||||
|
||||
|
||||
def _install_fake_smtp(monkeypatch) -> list[dict]:
|
||||
sent: list[dict] = []
|
||||
|
||||
class FakeSmtp:
|
||||
def __init__(self, host, port, timeout=10):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
pass
|
||||
|
||||
def login(self, username, password):
|
||||
pass
|
||||
|
||||
def sendmail(self, from_addr, to_addrs, message):
|
||||
sent.append({"to": to_addrs, "message": message})
|
||||
|
||||
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
|
||||
return sent
|
||||
|
||||
|
||||
async def _register_admin_and_login(client: TestClient, db_session: AsyncSession) -> dict[str, str]:
|
||||
email = _unique_email()
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Admin"},
|
||||
)
|
||||
user = await UserRepository(db_session).get_by_email(email)
|
||||
user.is_admin = True
|
||||
await db_session.commit()
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def test_unban_request_requires_no_auth_and_returns_204(monkeypatch):
|
||||
_install_fake_smtp(monkeypatch)
|
||||
ip = _unique_ip()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
resp = c.post("/api/v1/unban-requests", json={"message": "please unban me"})
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
def test_unban_request_cooldown_rejects_second_request_within_24h(monkeypatch):
|
||||
_install_fake_smtp(monkeypatch)
|
||||
ip = _unique_ip()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
first = c.post("/api/v1/unban-requests", json={"message": "one"})
|
||||
assert first.status_code == 204
|
||||
|
||||
second = c.post("/api/v1/unban-requests", json={"message": "two"})
|
||||
assert second.status_code == 429
|
||||
|
||||
|
||||
async def test_unban_request_notifies_every_admin_account_only(monkeypatch, db_session):
|
||||
sent = _install_fake_smtp(monkeypatch)
|
||||
|
||||
admin_repo = UserRepository(db_session)
|
||||
admin_one = await admin_repo.create(
|
||||
email=_unique_email(),
|
||||
password_hash="x",
|
||||
display_name="Admin One",
|
||||
timezone="UTC",
|
||||
is_admin=True,
|
||||
email_verified=True,
|
||||
)
|
||||
admin_two = await admin_repo.create(
|
||||
email=_unique_email(),
|
||||
password_hash="x",
|
||||
display_name="Admin Two",
|
||||
timezone="UTC",
|
||||
is_admin=True,
|
||||
email_verified=True,
|
||||
)
|
||||
not_admin = await admin_repo.create(
|
||||
email=_unique_email(),
|
||||
password_hash="x",
|
||||
display_name="Not Admin",
|
||||
timezone="UTC",
|
||||
is_admin=False,
|
||||
email_verified=True,
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
ip = _unique_ip()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
resp = c.post("/api/v1/unban-requests", json={"message": "please unban me"})
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Other tests in the same run may have their own admin accounts (plus
|
||||
# the fixed local-dev user, always admin) - assert membership, not an
|
||||
# exact total count.
|
||||
recipients = {msg["to"][0] for msg in sent}
|
||||
assert admin_one.email in recipients
|
||||
assert admin_two.email in recipients
|
||||
assert not_admin.email not in recipients
|
||||
|
||||
|
||||
async def test_admin_ip_ban_endpoints_work_for_an_admin(db_session: AsyncSession):
|
||||
ip = _unique_ip()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
headers = await _register_admin_and_login(c, db_session)
|
||||
|
||||
bans_resp = c.get("/api/v1/admin/ip-bans", headers=headers)
|
||||
assert bans_resp.status_code == 200
|
||||
|
||||
requests_resp = c.get("/api/v1/admin/unban-requests", headers=headers)
|
||||
assert requests_resp.status_code == 200
|
||||
|
||||
|
||||
def test_admin_ip_ban_endpoints_reject_non_admin(client: TestClient):
|
||||
email = _unique_email()
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
resp = client.get("/api/v1/admin/ip-bans", headers=headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_can_delete_ip_ban_and_it_clears_throttle_state(db_session: AsyncSession):
|
||||
banned_ip = _unique_ip()
|
||||
await IpThrottleRepository(db_session).create_ban(banned_ip, "failed_login", datetime.now(UTC))
|
||||
await db_session.commit()
|
||||
|
||||
admin_ip = _unique_ip()
|
||||
with TestClient(app, client=(admin_ip, 51235)) as c:
|
||||
headers = await _register_admin_and_login(c, db_session)
|
||||
resp = c.delete(f"/api/v1/admin/ip-bans/{banned_ip}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
ban = await IpThrottleRepository(db_session).get_ban(banned_ip)
|
||||
assert ban is None
|
||||
|
||||
|
||||
async def test_admin_can_manually_ban_an_ip(db_session: AsyncSession):
|
||||
target_ip = _unique_ip()
|
||||
admin_ip = _unique_ip()
|
||||
with TestClient(app, client=(admin_ip, 51236)) as c:
|
||||
headers = await _register_admin_and_login(c, db_session)
|
||||
resp = c.post("/api/v1/admin/ip-bans", json={"ip_address": target_ip}, headers=headers)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["ip_address"] == target_ip
|
||||
|
||||
ban = await IpThrottleRepository(db_session).get_ban(target_ip)
|
||||
assert ban is not None
|
||||
|
||||
|
||||
async def test_admin_ban_ip_rejects_already_banned_ip(db_session: AsyncSession):
|
||||
target_ip = _unique_ip()
|
||||
await IpThrottleRepository(db_session).create_ban(target_ip, "failed_login", datetime.now(UTC))
|
||||
await db_session.commit()
|
||||
|
||||
admin_ip = _unique_ip()
|
||||
with TestClient(app, client=(admin_ip, 51237)) as c:
|
||||
headers = await _register_admin_and_login(c, db_session)
|
||||
resp = c.post("/api/v1/admin/ip-bans", json={"ip_address": target_ip}, headers=headers)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
async def test_admin_ban_ip_rejects_malformed_address(db_session: AsyncSession):
|
||||
admin_ip = _unique_ip()
|
||||
with TestClient(app, client=(admin_ip, 51238)) as c:
|
||||
headers = await _register_admin_and_login(c, db_session)
|
||||
resp = c.post("/api/v1/admin/ip-bans", json={"ip_address": "not-an-ip"}, headers=headers)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_admin_can_accept_an_unban_request_and_it_unbans_the_ip(db_session: AsyncSession):
|
||||
requester_ip = _unique_ip()
|
||||
await IpThrottleRepository(db_session).create_ban(
|
||||
requester_ip, "failed_login", datetime.now(UTC)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
with TestClient(app, client=(requester_ip, 51240)) as c:
|
||||
c.post("/api/v1/unban-requests", json={"message": "please unban me"})
|
||||
|
||||
request = await UnbanRequestRepository(db_session).most_recent_for_ip(requester_ip)
|
||||
assert request is not None
|
||||
|
||||
admin_ip = _unique_ip()
|
||||
with TestClient(app, client=(admin_ip, 51241)) as c:
|
||||
headers = await _register_admin_and_login(c, db_session)
|
||||
resp = c.post(f"/api/v1/admin/unban-requests/{request.id}/accept", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
assert await IpThrottleRepository(db_session).get_ban(requester_ip) is None
|
||||
assert await UnbanRequestRepository(db_session).get(request.id) is None
|
||||
|
||||
|
||||
async def test_admin_can_reject_an_unban_request_and_the_ip_stays_banned(db_session: AsyncSession):
|
||||
requester_ip = _unique_ip()
|
||||
await IpThrottleRepository(db_session).create_ban(
|
||||
requester_ip, "failed_login", datetime.now(UTC)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
with TestClient(app, client=(requester_ip, 51242)) as c:
|
||||
c.post("/api/v1/unban-requests", json={"message": "please unban me"})
|
||||
|
||||
request = await UnbanRequestRepository(db_session).most_recent_for_ip(requester_ip)
|
||||
assert request is not None
|
||||
|
||||
admin_ip = _unique_ip()
|
||||
with TestClient(app, client=(admin_ip, 51243)) as c:
|
||||
headers = await _register_admin_and_login(c, db_session)
|
||||
resp = c.delete(f"/api/v1/admin/unban-requests/{request.id}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
assert await IpThrottleRepository(db_session).get_ban(requester_ip) is not None
|
||||
assert await UnbanRequestRepository(db_session).get(request.id) is None
|
||||
|
||||
|
||||
async def test_admin_accept_unban_request_404s_for_unknown_id(db_session: AsyncSession):
|
||||
admin_ip = _unique_ip()
|
||||
with TestClient(app, client=(admin_ip, 51244)) as c:
|
||||
headers = await _register_admin_and_login(c, db_session)
|
||||
resp = c.post(f"/api/v1/admin/unban-requests/{uuid.uuid4()}/accept", headers=headers)
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Per-user API keys: encryption roundtrip, repository/service behavior,
|
||||
endpoint auth/ownership, and one end-to-end check that a user's own key is
|
||||
actually used (not just stored) for a real provider call."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.crypto import decrypt_secret, encrypt_secret
|
||||
from app.main import app
|
||||
from app.models.enums import ApiKeyProvider
|
||||
from app.repositories.user_api_key_repository import UserApiKeyRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.services import user_api_key_service
|
||||
|
||||
|
||||
def _unique_email() -> str:
|
||||
return f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
|
||||
|
||||
def _register_and_login(client: TestClient) -> dict[str, str]:
|
||||
email = _unique_email()
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
# --- Encryption --------------------------------------------------------
|
||||
|
||||
|
||||
def test_encrypt_decrypt_roundtrip():
|
||||
settings = get_settings()
|
||||
ciphertext = encrypt_secret("sk-real-secret-value", settings)
|
||||
assert ciphertext != "sk-real-secret-value"
|
||||
assert decrypt_secret(ciphertext, settings) == "sk-real-secret-value"
|
||||
|
||||
|
||||
def test_decrypt_with_wrong_key_raises():
|
||||
settings = get_settings()
|
||||
other_key_settings = settings.model_copy(
|
||||
update={"api_key_encryption_secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}
|
||||
)
|
||||
ciphertext = encrypt_secret("sk-real-secret-value", settings)
|
||||
with pytest.raises(ValueError):
|
||||
decrypt_secret(ciphertext, other_key_settings)
|
||||
|
||||
|
||||
# --- Repository ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_repository_upsert_then_get_then_delete(db_session):
|
||||
repo = UserApiKeyRepository(db_session)
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
await repo.upsert(user_id, ApiKeyProvider.ANTHROPIC, "encrypted-1")
|
||||
row = await repo.get(user_id, ApiKeyProvider.ANTHROPIC)
|
||||
assert row is not None
|
||||
assert row.encrypted_key == "encrypted-1"
|
||||
|
||||
await repo.upsert(user_id, ApiKeyProvider.ANTHROPIC, "encrypted-2")
|
||||
row = await repo.get(user_id, ApiKeyProvider.ANTHROPIC)
|
||||
assert row.encrypted_key == "encrypted-2" # updated in place, not duplicated
|
||||
|
||||
await repo.delete(user_id, ApiKeyProvider.ANTHROPIC)
|
||||
assert await repo.get(user_id, ApiKeyProvider.ANTHROPIC) is None
|
||||
|
||||
|
||||
# --- Service ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_list_status_shows_all_four_providers_unconfigured_by_default(db_session):
|
||||
settings = get_settings()
|
||||
statuses = await user_api_key_service.list_status(db_session, uuid.uuid4(), settings)
|
||||
assert {s["provider"] for s in statuses} == {
|
||||
"anthropic",
|
||||
"brave_search",
|
||||
"ninjapear",
|
||||
"uspto",
|
||||
}
|
||||
assert all(s["configured"] is False for s in statuses)
|
||||
assert all(s["value"] is None for s in statuses)
|
||||
|
||||
uspto = next(s for s in statuses if s["provider"] == "uspto")
|
||||
assert uspto["free"] is True
|
||||
assert uspto["requires_government_id"] is True
|
||||
|
||||
|
||||
async def test_set_key_then_list_status_shows_it_configured(db_session):
|
||||
settings = get_settings()
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
await user_api_key_service.set_key(
|
||||
db_session,
|
||||
user_id,
|
||||
ApiKeyProvider.ANTHROPIC,
|
||||
"sk-my-real-key",
|
||||
settings,
|
||||
client_ip="10.0.0.1",
|
||||
)
|
||||
statuses = await user_api_key_service.list_status(db_session, user_id, settings)
|
||||
anthropic = next(s for s in statuses if s["provider"] == "anthropic")
|
||||
assert anthropic["configured"] is True
|
||||
assert anthropic["value"] == "sk-my-real-key"
|
||||
|
||||
|
||||
async def test_set_blank_key_clears_a_previously_set_one(db_session):
|
||||
settings = get_settings()
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
await user_api_key_service.set_key(
|
||||
db_session,
|
||||
user_id,
|
||||
ApiKeyProvider.ANTHROPIC,
|
||||
"sk-my-real-key",
|
||||
settings,
|
||||
client_ip="10.0.0.1",
|
||||
)
|
||||
await user_api_key_service.set_key(
|
||||
db_session, user_id, ApiKeyProvider.ANTHROPIC, " ", settings, client_ip="10.0.0.1"
|
||||
)
|
||||
|
||||
statuses = await user_api_key_service.list_status(db_session, user_id, settings)
|
||||
anthropic = next(s for s in statuses if s["provider"] == "anthropic")
|
||||
assert anthropic["configured"] is False
|
||||
assert anthropic["value"] is None
|
||||
|
||||
|
||||
async def test_get_effective_settings_falls_back_to_global_when_unset(db_session):
|
||||
settings = get_settings().model_copy(update={"anthropic_api_key": "global-key"})
|
||||
effective = await user_api_key_service.get_effective_settings(
|
||||
db_session, uuid.uuid4(), settings
|
||||
)
|
||||
assert effective.anthropic_api_key == "global-key"
|
||||
|
||||
|
||||
async def test_get_effective_settings_overrides_only_the_providers_the_user_set(db_session):
|
||||
settings = get_settings().model_copy(
|
||||
update={"anthropic_api_key": "global-anthropic", "brave_search_api_key": "global-brave"}
|
||||
)
|
||||
user_id = uuid.uuid4()
|
||||
await user_api_key_service.set_key(
|
||||
db_session,
|
||||
user_id,
|
||||
ApiKeyProvider.ANTHROPIC,
|
||||
"my-own-anthropic-key",
|
||||
settings,
|
||||
client_ip="10.0.0.1",
|
||||
)
|
||||
|
||||
effective = await user_api_key_service.get_effective_settings(db_session, user_id, settings)
|
||||
assert effective.anthropic_api_key == "my-own-anthropic-key"
|
||||
assert effective.brave_search_api_key == "global-brave" # untouched, no override set
|
||||
|
||||
|
||||
async def test_list_status_never_fetches_ninjapear_credits_itself(db_session):
|
||||
"""list_status must never make its own live NinjaPear call - the
|
||||
frontend sources that number from /system/status's already-fetched
|
||||
ninjapear_credit_balance instead (see the Settings page's System
|
||||
configuration box), so credits is always None from this endpoint
|
||||
regardless of whether a key is configured."""
|
||||
settings = get_settings()
|
||||
user_id = uuid.uuid4()
|
||||
await user_api_key_service.set_key(
|
||||
db_session,
|
||||
user_id,
|
||||
ApiKeyProvider.NINJAPEAR,
|
||||
"my-ninjapear-key",
|
||||
settings,
|
||||
client_ip="10.0.0.1",
|
||||
)
|
||||
|
||||
with respx.mock:
|
||||
# No mock registered for nubela.co - respx raises if anything tries
|
||||
# to call it, proving list_status makes no such request.
|
||||
statuses = await user_api_key_service.list_status(db_session, user_id, settings)
|
||||
|
||||
ninjapear = next(s for s in statuses if s["provider"] == "ninjapear")
|
||||
assert ninjapear["configured"] is True
|
||||
assert ninjapear["credits"] is None
|
||||
|
||||
|
||||
# --- Endpoints ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_user_api_keys_requires_auth(client: TestClient):
|
||||
resp = client.get("/api/v1/user-api-keys")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_list_and_set_user_api_key_round_trip(client: TestClient):
|
||||
headers = _register_and_login(client)
|
||||
|
||||
initial = client.get("/api/v1/user-api-keys", headers=headers)
|
||||
assert initial.status_code == 200
|
||||
assert all(not s["configured"] for s in initial.json())
|
||||
|
||||
set_resp = client.put(
|
||||
"/api/v1/user-api-keys/anthropic", json={"key": "sk-set-via-api"}, headers=headers
|
||||
)
|
||||
assert set_resp.status_code == 200
|
||||
assert set_resp.json()["configured"] is True
|
||||
assert set_resp.json()["value"] == "sk-set-via-api"
|
||||
|
||||
after = client.get("/api/v1/user-api-keys", headers=headers)
|
||||
anthropic = next(s for s in after.json() if s["provider"] == "anthropic")
|
||||
assert anthropic["configured"] is True
|
||||
assert anthropic["value"] == "sk-set-via-api"
|
||||
|
||||
|
||||
def test_updating_your_own_api_key_is_logged_to_account_activity(client: TestClient):
|
||||
headers = _register_and_login(client)
|
||||
|
||||
client.put("/api/v1/user-api-keys/anthropic", json={"key": "sk-set-via-api"}, headers=headers)
|
||||
|
||||
events = client.get("/api/v1/auth/security-events", headers=headers).json()
|
||||
assert any(e["event_type"] == "api_key_updated" for e in events)
|
||||
|
||||
|
||||
def test_set_user_api_key_rejects_unknown_provider(client: TestClient):
|
||||
headers = _register_and_login(client)
|
||||
resp = client.put(
|
||||
"/api/v1/user-api-keys/not-a-real-provider", json={"key": "x"}, headers=headers
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_two_users_keys_are_fully_isolated(client: TestClient, db_session):
|
||||
headers_a = _register_and_login(client)
|
||||
headers_b = _register_and_login(client)
|
||||
|
||||
client.put("/api/v1/user-api-keys/anthropic", json={"key": "a-key"}, headers=headers_a)
|
||||
|
||||
b_keys = client.get("/api/v1/user-api-keys", headers=headers_b).json()
|
||||
anthropic_b = next(s for s in b_keys if s["provider"] == "anthropic")
|
||||
assert anthropic_b["configured"] is False
|
||||
assert anthropic_b["value"] is None
|
||||
|
||||
|
||||
# --- Provider wiring: the user's own key is actually used ------------------
|
||||
|
||||
|
||||
async def test_discover_endpoint_uses_the_callers_own_anthropic_and_brave_keys(
|
||||
client: TestClient, db_session, monkeypatch
|
||||
):
|
||||
"""End-to-end proof this isn't just stored and ignored - the actual
|
||||
outbound Brave Search call for this request carries the user's own
|
||||
key, not the server's global one."""
|
||||
monkeypatch.setattr("app.core.config.Settings.search_provider", "brave", raising=False)
|
||||
headers = _register_and_login(client)
|
||||
user = await UserRepository(db_session).get_by_email(
|
||||
client.get("/api/v1/auth/me", headers=headers).json()["email"]
|
||||
)
|
||||
settings = get_settings()
|
||||
await user_api_key_service.set_key(
|
||||
db_session,
|
||||
user.id,
|
||||
ApiKeyProvider.BRAVE_SEARCH,
|
||||
"my-own-brave-key",
|
||||
settings,
|
||||
client_ip="10.0.0.1",
|
||||
)
|
||||
|
||||
seen_auth_tokens: list[str] = []
|
||||
|
||||
def _capture(request: httpx.Request) -> httpx.Response:
|
||||
seen_auth_tokens.append(request.headers.get("X-Subscription-Token", ""))
|
||||
return httpx.Response(200, json={"web": {"results": []}})
|
||||
|
||||
test_settings = get_settings().model_copy(update={"search_provider": "brave"})
|
||||
app.dependency_overrides[get_settings] = lambda: test_settings
|
||||
try:
|
||||
with respx.mock:
|
||||
respx.get(url__regex=r"https://api\.search\.brave\.com/.*").mock(side_effect=_capture)
|
||||
client.post("/api/v1/companies/discover", json={"name": "Acme Corp"}, headers=headers)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_settings, None)
|
||||
|
||||
assert "my-own-brave-key" in seen_auth_tokens
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Per-account known-IP ledger: pure data capture on every recorded sign-in
|
||||
(real login and the local-dev bypass), one row per distinct (user, ip) pair,
|
||||
touched rather than duplicated on repeat visits from the same IP. Nothing
|
||||
currently reads this data - it's the foundation a later "new IP" security
|
||||
feature would query against."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db.base import ensure_aware_utc
|
||||
from app.main import app
|
||||
from app.models.user import LOCAL_DEV_USER_ID
|
||||
from app.repositories.user_known_ip_repository import UserKnownIpRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
|
||||
|
||||
def _unique_email() -> str:
|
||||
return f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
|
||||
|
||||
def _unique_ip() -> str:
|
||||
return f"10.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}"
|
||||
|
||||
|
||||
# --- Repository --------------------------------------------------------
|
||||
|
||||
|
||||
async def test_record_login_creates_a_row_for_a_new_ip_and_reports_it_as_new(db_session):
|
||||
repo = UserKnownIpRepository(db_session)
|
||||
user_id = uuid.uuid4()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
is_new = await repo.record_login(user_id, "203.0.113.5", now)
|
||||
assert is_new is True
|
||||
|
||||
row = await repo.get(user_id, "203.0.113.5")
|
||||
assert row is not None
|
||||
assert ensure_aware_utc(row.first_seen_at) == now
|
||||
assert ensure_aware_utc(row.last_seen_at) == now
|
||||
|
||||
|
||||
async def test_record_login_touches_last_seen_instead_of_duplicating(db_session):
|
||||
repo = UserKnownIpRepository(db_session)
|
||||
user_id = uuid.uuid4()
|
||||
first_seen = datetime.now(UTC) - timedelta(days=1)
|
||||
second_visit = datetime.now(UTC)
|
||||
|
||||
await repo.record_login(user_id, "203.0.113.6", first_seen)
|
||||
is_new = await repo.record_login(user_id, "203.0.113.6", second_visit)
|
||||
|
||||
assert is_new is False
|
||||
rows = await repo.list_for_user(user_id)
|
||||
assert len(rows) == 1
|
||||
assert ensure_aware_utc(rows[0].first_seen_at) == first_seen
|
||||
assert ensure_aware_utc(rows[0].last_seen_at) == second_visit
|
||||
|
||||
|
||||
async def test_a_second_distinct_ip_creates_a_second_row(db_session):
|
||||
repo = UserKnownIpRepository(db_session)
|
||||
user_id = uuid.uuid4()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
await repo.record_login(user_id, "203.0.113.7", now)
|
||||
await repo.record_login(user_id, "203.0.113.8", now)
|
||||
|
||||
rows = await repo.list_for_user(user_id)
|
||||
assert {r.ip_address for r in rows} == {"203.0.113.7", "203.0.113.8"}
|
||||
|
||||
|
||||
# --- Wired into real sign-in flows --------------------------------------
|
||||
|
||||
|
||||
async def test_real_login_records_the_client_ip(db_session):
|
||||
ip = _unique_ip()
|
||||
email = _unique_email()
|
||||
with TestClient(app, client=(ip, 51234)) as c:
|
||||
c.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
login_resp = c.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
user = await UserRepository(db_session).get_by_email(email)
|
||||
rows = await UserKnownIpRepository(db_session).list_for_user(user.id)
|
||||
assert [r.ip_address for r in rows] == [ip]
|
||||
|
||||
|
||||
async def test_repeat_login_from_the_same_ip_does_not_duplicate_the_row(db_session):
|
||||
ip = _unique_ip()
|
||||
email = _unique_email()
|
||||
with TestClient(app, client=(ip, 51235)) as c:
|
||||
c.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
c.post("/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"})
|
||||
c.post("/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"})
|
||||
|
||||
user = await UserRepository(db_session).get_by_email(email)
|
||||
rows = await UserKnownIpRepository(db_session).list_for_user(user.id)
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
async def test_local_dev_sign_in_records_the_known_ip(local_mode_client, db_session):
|
||||
# local_mode_client's fixed loopback peer (see conftest.py) should show
|
||||
# up as a known IP for the local-dev account after this request.
|
||||
resp = local_mode_client.get("/api/v1/auth/me")
|
||||
assert resp.status_code == 200
|
||||
|
||||
rows = await UserKnownIpRepository(db_session).list_for_user(LOCAL_DEV_USER_ID)
|
||||
assert "127.0.0.1" in {r.ip_address for r in rows}
|
||||
Reference in New Issue
Block a user