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).
118 lines
4.4 KiB
Python
118 lines
4.4 KiB
Python
"""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
|