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
@@ -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()