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).
127 lines
4.7 KiB
Python
127 lines
4.7 KiB
Python
"""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
|