Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,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()
|
||||
Reference in New Issue
Block a user