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:
2026-08-05 10:48:20 -04:00
commit 1a4c80958f
365 changed files with 43541 additions and 0 deletions
View File
+24
View File
@@ -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&amp;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
+87
View File
@@ -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)
+13
View File
@@ -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>
+12
View File
@@ -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>
+12
View File
@@ -0,0 +1,12 @@
<html>
<head><title>Press - Acme Mobility Systems</title></head>
<body>
<article>
<h1>Press &amp; 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>
+11
View File
@@ -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>
+11
View File
@@ -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>
+14
View File
@@ -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>
+13
View File
@@ -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>
+15
View File
@@ -0,0 +1,15 @@
<html>
<head><title>Press - Acme Mobility Systems</title></head>
<body>
<article>
<h1>Press &amp; 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>
+11
View File
@@ -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>
+11
View File
@@ -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>
+11
View File
@@ -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"
}
]
}
+20
View File
@@ -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()
View File
+173
View File
@@ -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"]
+200
View File
@@ -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
+254
View File
@@ -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)
+254
View File
@@ -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
+38
View File
@@ -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
+67
View File
@@ -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"
+22
View File
@@ -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
+25
View File
@@ -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
+179
View File
@@ -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
+30
View File
@@ -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
+89
View File
@@ -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"] == {}
+91
View File
@@ -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
)
+130
View File
@@ -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"
+654
View File
@@ -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."
+98
View File
@@ -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
+190
View File
@@ -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
+77
View File
@@ -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
+294
View File
@@ -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
+34
View File
@@ -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
+263
View File
@@ -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
+289
View File
@@ -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
+118
View File
@@ -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}