dotb_ai-platform/tests/test_notifications.py

207 lines
8.1 KiB
Python

import asyncio
import hashlib
import hmac
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import httpx
import pytest
from pydantic import SecretStr, ValidationError
from sqlalchemy import delete
from ai_platform.infrastructure.config.settings import Settings
from ai_platform.infrastructure.persistence.database import make_session_factory
from ai_platform.infrastructure.persistence.models import ScoringNotification, ScoringRun, TenantKey
from ai_platform.infrastructure.persistence.notifications import SqlAlchemyNotificationRepository
from ai_platform.infrastructure.webhook import OmniWebhook
from ai_platform.lead_scoring.application.notifications import Notification, NotificationUseCases
from ai_platform.platform.security import digest
NOW = datetime(2026, 9, 28, tzinfo=UTC)
SECRET = "test-only-webhook-secret-32-characters-long"
def test_webhook_configuration_requires_pair_and_safe_url():
with pytest.raises(ValidationError, match="configured together"):
Settings(webhook_url="https://omni.example.invalid/events")
with pytest.raises(ValidationError, match="HTTPS outside local development"):
Settings(
webhook_url="http://omni.example.invalid/events",
webhook_signing_secret=SecretStr(SECRET),
)
with pytest.raises(ValidationError, match="HTTPS outside local development"):
Settings(
webhook_url="https://omni.example.invalid/events?token=secret",
webhook_signing_secret=SecretStr(SECRET),
)
assert Settings(
webhook_url="https://omni.example.invalid/events", webhook_signing_secret=SecretStr(SECRET)
).webhook_url
async def test_signature_contains_only_notification_identifiers_and_status():
fixture = json.loads(
(
Path(__file__).resolve().parents[1]
/ "contracts/lead-scoring/v2/fixtures/webhook-delivery.json"
).read_text()
)
body = json.loads(fixture["body"])
sent_at = datetime.fromisoformat(fixture["timestamp"])
requests: list[httpx.Request] = []
def receiver(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(204)
async with httpx.AsyncClient(transport=httpx.MockTransport(receiver)) as client:
webhook = OmniWebhook(
client, "https://omni.example.invalid/events", SECRET, lambda: sent_at
)
event = Notification(
body["event_id"],
body["run_id"],
body["tenant_id"],
body["status"],
datetime.fromisoformat(body["occurred_at"]),
0,
)
assert await webhook.send(event)
request = requests[0]
assert request.method == fixture["method"]
assert request.headers["Content-Type"] == fixture["content_type"]
assert request.headers["X-Webhook-Timestamp"] == fixture["timestamp"]
assert request.content.decode() == fixture["body"]
assert request.headers["X-Webhook-Signature"] == fixture["signature"]
assert hmac.new(
SECRET.encode(), fixture["timestamp"].encode() + b"." + request.content, hashlib.sha256
).hexdigest() == fixture["signature"].removeprefix("sha256=")
assert "Authorization" not in request.headers
async def test_retry_is_bounded_and_preserves_dead_letter():
class Repository:
def __init__(self):
self.event = Notification("event-1", "run-1", "tenant-1", "completed", NOW, 0)
self.dead = False
self.next_at = NOW
async def next_due(self, now):
return self.event if not self.dead and self.next_at <= now else None
async def delivered(self, event_id, now):
pytest.fail("unexpected success")
async def retry(self, event_id, attempts, next_at, dead):
self.event = Notification("event-1", "run-1", "tenant-1", "completed", NOW, attempts)
self.next_at = next_at
self.dead = dead
async def delete_expired_runs(self, cutoff):
pass
repo = Repository()
async def failed(_event):
return False
async def noop():
pass
current = NOW
cases = NotificationUseCases(repo, failed, noop, noop, lambda: current)
for attempt in range(1, 6):
assert await cases.dispatch_one()
assert repo.event.attempts == attempt
current = repo.next_at
assert repo.dead
assert not await cases.dispatch_one()
async def test_retention_deletes_old_run_and_its_outbox_but_keeps_recent_data():
settings = Settings.load()
if not settings.database_url.rstrip("/").endswith("/ai_platform_test"):
pytest.fail("Retention test requires isolated ai_platform_test database")
factory = make_session_factory(settings.database_url)
suffix = uuid4().hex
tenant_id = f"retention-{suffix}"
old_id, new_id = str(uuid4()), str(uuid4())
old_event_id, new_event_id = str(uuid4()), str(uuid4())
async def run():
try:
async with factory() as db:
db.add(TenantKey(tenant_id=tenant_id, verifier=digest("test-key")))
await db.flush()
for run_id, event_id, created_at in (
(old_id, old_event_id, NOW - timedelta(days=91)),
(new_id, new_event_id, NOW - timedelta(days=89)),
):
db.add(
ScoringRun(
id=run_id,
tenant_id=tenant_id,
request_id=run_id,
fingerprint="0" * 64,
request={"private": "snapshot"},
profile_version="test",
status="failed",
created_at=created_at,
completed_at=created_at,
)
)
await db.flush()
db.add(
ScoringNotification(
id=event_id,
run_id=run_id,
tenant_id=tenant_id,
status="failed",
attempts=5 if run_id == old_id else 0,
next_attempt_at=created_at,
created_at=created_at,
dead=run_id == old_id,
)
)
await db.commit()
async with factory() as db:
cases = NotificationUseCases(
SqlAlchemyNotificationRepository(db),
lambda _event: asyncio.sleep(0, result=True),
db.commit,
db.rollback,
lambda: NOW,
)
await cases.delete_expired()
async with factory() as db:
assert await db.get(ScoringRun, old_id) is None
assert await db.get(ScoringNotification, old_event_id) is None
assert await db.get(ScoringRun, new_id) is not None
assert await db.get(ScoringNotification, new_event_id) is not None
sent: list[Notification] = []
async def send(event: Notification) -> bool:
sent.append(event)
return True
async with factory() as db:
cases = NotificationUseCases(
SqlAlchemyNotificationRepository(db), send, db.commit, db.rollback, lambda: NOW
)
assert await cases.dispatch_one()
assert [event.id for event in sent] == [new_event_id]
async with factory() as db:
delivered = await db.get(ScoringNotification, new_event_id)
assert delivered is not None and delivered.delivered_at == NOW
assert delivered.attempts == 1
finally:
async with factory() as db:
await db.execute(delete(ScoringRun).where(ScoringRun.tenant_id == tenant_id))
await db.execute(delete(TenantKey).where(TenantKey.tenant_id == tenant_id))
await db.commit()
await factory.kw["bind"].dispose()
await run()