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,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
|
||||
Reference in New Issue
Block a user