dotb_ai-platform/old_repo/tests/test_webhook_dispatcher.py

1395 lines
50 KiB
Python

import asyncio
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
import httpx
import pytest
from alembic import command
from alembic.config import Config
from pydantic import SecretStr
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from lead_scoring.config import Settings
from lead_scoring.db.models.outbox_event import OutboxEvent
from lead_scoring.db.models.scoring_run import ScoringRun
from lead_scoring.main import create_app
from lead_scoring.schemas.scoring_run import ScoringRunStatus
from lead_scoring.schemas.webhook import (
OutboxDeliveryStatus,
)
from lead_scoring.webhook.dispatcher import (
DeliveryClassification,
DeliveryResult,
WebhookDispatcher,
calculate_backoff,
claim_outbox_events,
dispatch_single_event,
record_delivery_result,
resolve_webhook_target_url,
)
from lead_scoring.webhook.signing import (
TEST_SIGNING_SECRET,
sanitize_secret,
sign_webhook,
)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
def get_alembic_config(database_url: str) -> Config:
ini_path = PROJECT_ROOT / "alembic.ini"
alembic_cfg = Config(str(ini_path))
alembic_cfg.attributes["sqlalchemy_url"] = database_url
return alembic_cfg
def test_calculate_backoff_deterministic() -> None:
assert calculate_backoff(0, base_seconds=2.0, max_seconds=60.0) == 0.0
assert calculate_backoff(1, base_seconds=2.0, max_seconds=60.0) == 2.0
assert calculate_backoff(2, base_seconds=2.0, max_seconds=60.0) == 4.0
assert calculate_backoff(3, base_seconds=2.0, max_seconds=60.0) == 8.0
assert calculate_backoff(4, base_seconds=2.0, max_seconds=60.0) == 16.0
assert calculate_backoff(5, base_seconds=2.0, max_seconds=60.0) == 32.0
assert calculate_backoff(6, base_seconds=2.0, max_seconds=60.0) == 60.0
assert calculate_backoff(10, base_seconds=2.0, max_seconds=60.0) == 60.0
def test_resolve_webhook_target_url() -> None:
assert (
resolve_webhook_target_url("http://localhost:8080")
== "http://localhost:8080/api/v1/internal/lead-scoring/events"
)
assert (
resolve_webhook_target_url("http://localhost:8080/")
== "http://localhost:8080/api/v1/internal/lead-scoring/events"
)
assert (
resolve_webhook_target_url("http://localhost:8080/api/v1/internal/lead-scoring/events")
== "http://localhost:8080/api/v1/internal/lead-scoring/events"
)
with pytest.raises(ValueError, match="path must be empty"):
resolve_webhook_target_url("https://omni.dotb.vn/custom/path")
def test_settings_rejects_arbitrary_webhook_endpoint_path() -> None:
with pytest.raises(ValueError, match="path must be empty"):
Settings(
webhook_subscriber_url="https://omni.dotb.vn/custom/path",
webhook_signing_secret=SecretStr("test-secret"),
)
def test_sign_webhook_matches_released_contract_signature_vectors() -> None:
vectors_file = (
PROJECT_ROOT / "contracts" / "lead-scoring-webhook" / "v1" / "signature-vectors.json"
)
vectors_data = json.loads(vectors_file.read_text(encoding="utf-8"))
test_secret = vectors_data["test_secret"]
assert test_secret == TEST_SIGNING_SECRET
for vector in vectors_data["vectors"]:
body_file = PROJECT_ROOT / "contracts" / "lead-scoring-webhook" / "v1" / vector["body_file"]
body_bytes = body_file.read_bytes()
calculated_sig = sign_webhook(
secret=test_secret,
event_id=vector["event_id"],
timestamp=vector["timestamp"],
body=body_bytes,
)
assert calculated_sig == vector["signature"]
def test_signature_changes_with_fresh_timestamp_on_retry() -> None:
event_id = "11111111-1111-4111-8111-111111111111"
body = b'{"hello": "world"}\n'
sig1 = sign_webhook(TEST_SIGNING_SECRET, event_id, "1787832600", body)
sig2 = sign_webhook(TEST_SIGNING_SECRET, event_id, "1787832660", body)
assert sig1 != sig2
assert sig1.startswith("v1=")
assert sig2.startswith("v1=")
def test_secret_sanitization() -> None:
secret = "whsec_super_confidential_key_12345"
raw_error = f"Failed to authenticate with secret {secret} against endpoint"
sanitized = sanitize_secret(raw_error, secret)
assert secret not in sanitized
assert "[REDACTED]" in sanitized
assert sanitize_secret("No secret here", None) == "No secret here"
@pytest.fixture
async def migrated_db(temp_postgres_db: str):
alembic_cfg = get_alembic_config(temp_postgres_db)
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
engine = create_async_engine(temp_postgres_db)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
try:
yield session_factory, engine, temp_postgres_db
finally:
await engine.dispose()
await asyncio.to_thread(command.downgrade, alembic_cfg, "base")
@pytest.mark.asyncio
async def test_claim_outbox_events_single_and_concurrent(migrated_db):
session_factory, _, _ = migrated_db
now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC)
# Insert 5 events (3 pending, 1 delivering with active lease, 1 delivering with expired lease)
async with session_factory() as session:
for i in range(1, 4):
session.add(
OutboxEvent(
id=f"00000000-0000-0000-0000-00000000000{i}",
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id=f"run-{i}",
payload={"status": "completed"},
occurred_at=now - timedelta(minutes=10 - i),
delivery_status=OutboxDeliveryStatus.PENDING.value,
)
)
# Active delivering event
session.add(
OutboxEvent(
id="00000000-0000-0000-0000-000000000004",
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id="run-4",
payload={"status": "completed"},
occurred_at=now - timedelta(minutes=5),
delivery_status=OutboxDeliveryStatus.DELIVERING.value,
lease_expires_at=now + timedelta(seconds=20),
)
)
# Expired delivering event (crash recovery)
session.add(
OutboxEvent(
id="00000000-0000-0000-0000-000000000005",
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id="run-5",
payload={"status": "completed"},
occurred_at=now - timedelta(minutes=4),
delivery_status=OutboxDeliveryStatus.DELIVERING.value,
lease_expires_at=now - timedelta(seconds=5),
)
)
await session.commit()
# Claim batch of 10
async with session_factory() as session:
claimed = await claim_outbox_events(
session=session, batch_size=10, lease_seconds=30.0, now=now
)
await session.commit()
claimed_ids = {e.id for e in claimed}
# Should claim 1, 2, 3 (pending) and 5 (expired lease), but NOT 4 (active lease)
assert len(claimed) == 4
assert claimed_ids == {
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
"00000000-0000-0000-0000-000000000003",
"00000000-0000-0000-0000-000000000005",
}
for e in claimed:
assert e.delivery_status == OutboxDeliveryStatus.DELIVERING.value
assert e.lease_expires_at == now + timedelta(seconds=30.0)
@pytest.mark.asyncio
async def test_concurrent_dispatchers_no_duplicate_claims(migrated_db):
session_factory, _, _ = migrated_db
now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC)
# Insert 20 pending events
async with session_factory() as session:
for i in range(20):
session.add(
OutboxEvent(
id=f"00000000-0000-0000-0000-{i:012d}",
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id=f"run-{i}",
payload={"status": "completed"},
occurred_at=now + timedelta(seconds=i),
delivery_status=OutboxDeliveryStatus.PENDING.value,
)
)
await session.commit()
# 4 concurrent workers claim batches of 5 concurrently
async def worker_claim():
async with session_factory() as session:
claimed = await claim_outbox_events(
session=session, batch_size=5, lease_seconds=30.0, now=now
)
await session.commit()
return [e.id for e in claimed]
results = await asyncio.gather(
worker_claim(),
worker_claim(),
worker_claim(),
worker_claim(),
)
all_claimed_ids = [item for sublist in results for item in sublist]
assert len(all_claimed_ids) == 20
# No event claimed twice
assert len(set(all_claimed_ids)) == 20
@pytest.mark.asyncio
async def test_dispatch_all_200_ingest_statuses_mark_delivered(migrated_db):
session_factory, _, _ = migrated_db
now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC)
for ingest_status in ["accepted", "duplicate", "stale", "ordering_conflict"]:
event_id = f"00000000-0000-0000-0000-0000000000{ingest_status[:2]}"
run_id = f"run-200-{ingest_status}"
event = OutboxEvent(
id=event_id,
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id=run_id,
payload={"event_id": event_id, "data": {"status": "completed"}},
occurred_at=now,
delivery_status=OutboxDeliveryStatus.DELIVERING.value,
)
mock_transport = httpx.MockTransport(
lambda request, status=ingest_status: httpx.Response(
200,
json={"status": status, "event_id": "00000000-0000-0000-0000-000000000001"},
)
)
async with httpx.AsyncClient(transport=mock_transport) as client:
result = await dispatch_single_event(
event=event,
target_url="http://localhost:8080/api/v1/internal/lead-scoring/events",
signing_key_id="test-key",
signing_secret=TEST_SIGNING_SECRET,
timeout_seconds=5.0,
http_client=client,
now=now,
)
assert result.classification == DeliveryClassification.DELIVERED
assert result.http_status == 200
assert result.ingest_status == ingest_status
# Test recording result in DB
async with session_factory() as session:
session.add(event)
await session.commit()
await record_delivery_result(
session=session,
event=event,
result=result,
max_attempts=5,
backoff_base_seconds=2.0,
backoff_max_seconds=60.0,
now=now,
)
await session.commit()
async with session_factory() as check_session:
updated = await check_session.get(OutboxEvent, event_id)
assert updated is not None
assert updated.delivery_status == OutboxDeliveryStatus.DELIVERED.value
assert updated.delivered_at == now
assert updated.lease_expires_at is None
assert updated.last_http_status == 200
assert updated.last_error_code is None
@pytest.mark.asyncio
async def test_dispatch_terminal_400_and_401_go_to_dlq(migrated_db):
session_factory, _, _ = migrated_db
now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC)
cases = [
(400, "receiver_bad_request", "Bad JSON format"),
(401, "receiver_unauthorized", "Invalid signature"),
]
for status_code, expected_err, response_text in cases:
event_id = f"00000000-0000-0000-0000-000000000{status_code}"
run_id = f"run-err-{status_code}"
event = OutboxEvent(
id=event_id,
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id=run_id,
payload={"status": "completed"},
occurred_at=now,
delivery_status=OutboxDeliveryStatus.DELIVERING.value,
attempt_count=1,
)
mock_transport = httpx.MockTransport(
lambda request, sc=status_code, txt=response_text: httpx.Response(sc, text=txt)
)
async with httpx.AsyncClient(transport=mock_transport) as client:
result = await dispatch_single_event(
event=event,
target_url="http://localhost:8080/api/v1/internal/lead-scoring/events",
signing_key_id="test-key",
signing_secret=TEST_SIGNING_SECRET,
timeout_seconds=5.0,
http_client=client,
now=now,
)
assert result.classification == DeliveryClassification.DEAD_LETTER
assert result.http_status == status_code
assert result.error_code == expected_err
async with session_factory() as session:
session.add(event)
await session.commit()
await record_delivery_result(
session=session,
event=event,
result=result,
max_attempts=5,
backoff_base_seconds=2.0,
backoff_max_seconds=60.0,
now=now,
)
await session.commit()
async with session_factory() as check_session:
updated = await check_session.get(OutboxEvent, event_id)
assert updated is not None
assert updated.delivery_status == OutboxDeliveryStatus.DEAD_LETTER.value
assert updated.attempt_count == 1
assert updated.last_http_status == status_code
assert updated.last_error_code == expected_err
assert updated.last_error_detail == response_text
assert updated.lease_expires_at is None
@pytest.mark.asyncio
async def test_dispatch_terminal_404_tenant_unavailable_goes_to_dlq(migrated_db):
session_factory, _, _ = migrated_db
now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC)
event_id = "00000000-0000-0000-0000-000000000404"
response_text = (
'{"error_code":"subscriber_tenant_unavailable",'
'"detail":"Webhook destination is not available"}'
)
event = OutboxEvent(
id=event_id,
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id="run-tenant-unavailable",
payload={"status": "completed"},
occurred_at=now,
delivery_status=OutboxDeliveryStatus.DELIVERING.value,
attempt_count=1,
)
mock_transport = httpx.MockTransport(lambda request: httpx.Response(404, text=response_text))
async with httpx.AsyncClient(transport=mock_transport) as client:
result = await dispatch_single_event(
event=event,
target_url="http://localhost:8080/api/v1/internal/lead-scoring/events",
signing_key_id="test-key",
signing_secret=TEST_SIGNING_SECRET,
timeout_seconds=5.0,
http_client=client,
now=now,
)
assert result.classification == DeliveryClassification.DEAD_LETTER
assert result.http_status == 404
assert result.error_code == "receiver_client_error"
async with session_factory() as session:
session.add(event)
await session.commit()
recorded = await record_delivery_result(
session=session,
event=event,
result=result,
max_attempts=5,
backoff_base_seconds=2.0,
backoff_max_seconds=60.0,
now=now,
)
await session.commit()
assert recorded is True
async with session_factory() as session:
updated = await session.get(OutboxEvent, event_id)
assert updated is not None
assert updated.delivery_status == OutboxDeliveryStatus.DEAD_LETTER.value
assert updated.attempt_count == 1
assert updated.next_attempt_at is None
assert updated.claim_token is None
assert updated.lease_expires_at is None
assert updated.last_http_status == 404
assert updated.last_error_code == "receiver_client_error"
assert updated.last_error_detail == response_text
claimed = await claim_outbox_events(
session=session,
batch_size=1,
lease_seconds=30.0,
now=now + timedelta(minutes=1),
max_attempts=5,
)
assert claimed == []
@pytest.mark.asyncio
async def test_dispatch_retryable_429_500_and_timeout(migrated_db):
session_factory, _, _ = migrated_db
now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC)
# 1. 429 Rate limited on attempt 1
event_id = "00000000-0000-0000-0000-000000000429"
event = OutboxEvent(
id=event_id,
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id="run-429",
payload={"status": "completed"},
occurred_at=now,
delivery_status=OutboxDeliveryStatus.DELIVERING.value,
claim_token="token-429",
attempt_count=1,
)
mock_transport = httpx.MockTransport(
lambda req: httpx.Response(429, text="Rate limit exceeded")
)
async with httpx.AsyncClient(transport=mock_transport) as client:
result = await dispatch_single_event(
event=event,
target_url="http://localhost:8080/api/v1/internal/lead-scoring/events",
signing_key_id="test-key",
signing_secret=TEST_SIGNING_SECRET,
timeout_seconds=5.0,
http_client=client,
now=now,
)
assert result.classification == DeliveryClassification.RETRYABLE
assert result.http_status == 429
async with session_factory() as session:
session.add(event)
await session.commit()
await record_delivery_result(
session=session,
event=event,
result=result,
max_attempts=5,
backoff_base_seconds=2.0,
backoff_max_seconds=60.0,
now=now,
claim_token="token-429",
)
await session.commit()
async with session_factory() as check_session:
updated = await check_session.get(OutboxEvent, event_id)
assert updated is not None
assert updated.delivery_status == OutboxDeliveryStatus.PENDING.value
assert updated.attempt_count == 1
assert updated.next_attempt_at == now + timedelta(seconds=2.0)
assert updated.last_error_code == "receiver_rate_limited"
# 2. 500 Server error on attempt 2 -> backoff (4.0s)
event_500_id = "00000000-0000-0000-0000-000000000500"
event_500 = OutboxEvent(
id=event_500_id,
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id="run-500",
payload={"status": "completed"},
occurred_at=now,
delivery_status=OutboxDeliveryStatus.DELIVERING.value,
claim_token="token-500",
attempt_count=2,
)
mock_transport_500 = httpx.MockTransport(
lambda req: httpx.Response(500, text="Internal Server Error")
)
async with httpx.AsyncClient(transport=mock_transport_500) as client:
result_500 = await dispatch_single_event(
event=event_500,
target_url="http://localhost:8080/api/v1/internal/lead-scoring/events",
signing_key_id="test-key",
signing_secret=TEST_SIGNING_SECRET,
timeout_seconds=5.0,
http_client=client,
now=now,
)
assert result_500.classification == DeliveryClassification.RETRYABLE
async with session_factory() as session:
session.add(event_500)
await session.commit()
await record_delivery_result(
session=session,
event=event_500,
result=result_500,
max_attempts=5,
backoff_base_seconds=2.0,
backoff_max_seconds=60.0,
now=now,
claim_token="token-500",
)
await session.commit()
async with session_factory() as check_session:
updated_500 = await check_session.get(OutboxEvent, event_500_id)
assert updated_500 is not None
assert updated_500.delivery_status == OutboxDeliveryStatus.PENDING.value
assert updated_500.attempt_count == 2
assert updated_500.next_attempt_at == now + timedelta(seconds=4.0)
# 3. Timeout on attempt 5 -> reaches max_attempts 5 -> DEAD_LETTER
event_timeout_id = "00000000-0000-0000-0000-000000000504"
event_timeout = OutboxEvent(
id=event_timeout_id,
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id="run-timeout",
payload={"status": "completed"},
occurred_at=now,
delivery_status=OutboxDeliveryStatus.DELIVERING.value,
claim_token="token-timeout",
attempt_count=5,
)
timeout_result = DeliveryResult(
classification=DeliveryClassification.RETRYABLE,
error_code="delivery_timeout",
detail="Webhook delivery timed out",
)
async with session_factory() as session:
session.add(event_timeout)
await session.commit()
await record_delivery_result(
session=session,
event=event_timeout,
result=timeout_result,
max_attempts=5,
backoff_base_seconds=2.0,
backoff_max_seconds=60.0,
now=now,
claim_token="token-timeout",
)
await session.commit()
async with session_factory() as check_session:
updated_timeout = await check_session.get(OutboxEvent, event_timeout_id)
assert updated_timeout is not None
assert updated_timeout.delivery_status == OutboxDeliveryStatus.DEAD_LETTER.value
assert updated_timeout.attempt_count == 5
assert updated_timeout.last_error_code == "retries_exhausted"
@pytest.mark.asyncio
async def test_webhook_dispatcher_runtime_batch_processing(migrated_db):
session_factory, _, _ = migrated_db
now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC)
settings = Settings(
webhook_subscriber_url="http://localhost:8080/api/v1/internal/lead-scoring/events",
webhook_signing_secret=SecretStr(TEST_SIGNING_SECRET),
webhook_signing_key_id="pilot-test-2026-01",
webhook_max_attempts=3,
webhook_batch_size=10,
)
received_requests = []
def handle_request(request: httpx.Request):
received_requests.append(
{
"url": str(request.url),
"headers": dict(request.headers),
"body": json.loads(request.content.decode("utf-8")),
}
)
return httpx.Response(
200,
json={
"status": "accepted",
"event_id": request.headers.get(
"x-dotb-webhook-id", "00000000-0000-0000-0000-000000000000"
),
},
)
mock_client = httpx.AsyncClient(transport=httpx.MockTransport(handle_request))
# Insert 2 pending events
async with session_factory() as session:
session.add(
OutboxEvent(
id="00000000-0000-0000-0000-000000000101",
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id="run-101",
payload={
"event_id": "00000000-0000-0000-0000-000000000101",
"event_type": "lead_scoring.run.terminal",
"event_version": "1.0.0",
"tenant_id": "tenant_1",
"subject_ref": "lead_101",
"scoring_config_version": "1.0.0",
"run_id": "00000000-0000-0000-0000-000000000101",
"run_created_at": "2026-08-28T12:00:00Z",
"occurred_at": "2026-08-28T12:00:02Z",
"data": {"status": "completed", "rule_score": 85},
},
occurred_at=now,
delivery_status=OutboxDeliveryStatus.PENDING.value,
)
)
session.add(
OutboxEvent(
id="00000000-0000-0000-0000-000000000102",
event_type="lead_scoring.run.reviewed",
event_version="1.0.0",
run_id="run-102",
payload={
"event_id": "00000000-0000-0000-0000-000000000102",
"event_type": "lead_scoring.run.reviewed",
"event_version": "1.0.0",
"tenant_id": "tenant_1",
"subject_ref": "lead_102",
"scoring_config_version": "1.0.0",
"run_id": "00000000-0000-0000-0000-000000000102",
"run_created_at": "2026-08-28T12:00:00Z",
"occurred_at": "2026-08-28T12:05:00Z",
"data": {
"status": "completed",
"decision": "approve",
"calculated_score": 80,
"effective_score": 80,
"version": 2,
"reviewed_by": "admin_1",
},
},
occurred_at=now + timedelta(seconds=1),
delivery_status=OutboxDeliveryStatus.PENDING.value,
)
)
await session.commit()
dispatcher = WebhookDispatcher(
session_factory=session_factory,
settings=settings,
http_client=mock_client,
clock=lambda: now,
)
processed_count = await dispatcher.dispatch_batch()
assert processed_count == 2
assert len(received_requests) == 2
# Verify headers and HMAC signature on received request
req1 = received_requests[0]
assert req1["headers"]["x-dotb-webhook-id"] == "00000000-0000-0000-0000-000000000101"
assert req1["headers"]["x-dotb-webhook-key-id"] == "pilot-test-2026-01"
assert "x-dotb-webhook-signature" in req1["headers"]
assert req1["headers"]["x-dotb-webhook-signature"].startswith("v1=")
# Verify both rows in DB transitioned to delivered
async with session_factory() as session:
events = (await session.execute(select(OutboxEvent))).scalars().all()
assert len(events) == 2
for ev in events:
assert ev.delivery_status == OutboxDeliveryStatus.DELIVERED.value
assert ev.delivered_at is not None
await dispatcher.stop()
await mock_client.aclose()
@pytest.mark.asyncio
async def test_fastapi_lifespan_starts_and_stops_dispatcher(migrated_db):
session_factory, engine, db_url = migrated_db
settings = Settings(
webhook_subscriber_url="http://localhost:8080/api/v1/internal/lead-scoring/events",
webhook_signing_secret=SecretStr(TEST_SIGNING_SECRET),
database_url=db_url,
)
mock_client = httpx.AsyncClient(
transport=httpx.MockTransport(
lambda req: httpx.Response(
200, json={"status": "accepted", "event_id": "00000000-0000-0000-0000-000000000001"}
)
)
)
app = create_app(settings=settings, webhook_http_client=mock_client)
# Trigger lifespan
async with app.router.lifespan_context(app):
# Check app state has dispatcher
dispatcher = getattr(app.state, "webhook_dispatcher", None)
assert dispatcher is not None
assert isinstance(dispatcher, WebhookDispatcher)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://testserver",
) as client:
# Health endpoint should be healthy
resp = await client.get("/health")
assert resp.status_code == 200
await mock_client.aclose()
@pytest.mark.asyncio
async def test_e2e_scoring_run_execution_outbox_to_webhook_delivery(migrated_db):
session_factory, engine, db_url = migrated_db
now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC)
from lead_scoring.config import ServiceCallerConfig
from lead_scoring.execution import execute_scoring_run
from lead_scoring.scoring import ScoringConfig
scoring_config = ScoringConfig(
version="1.0.0",
base_score=85,
rule_weight=1.0,
ai_weight=0.0,
confidence=0.9,
confidence_threshold=0.8,
hitl_policy_version="1.0.0",
rules=(),
)
settings = Settings(
webhook_subscriber_url="http://localhost:8080/api/v1/internal/lead-scoring/events",
webhook_signing_secret=SecretStr(TEST_SIGNING_SECRET),
webhook_signing_key_id="pilot-test-2026-01",
database_url=db_url,
scoring_configs=[scoring_config],
service_callers=[
ServiceCallerConfig(
caller_id="test_caller",
token=SecretStr("valid_token"),
allowed_apps=["omni"],
allowed_features=["scoring"],
allowed_tenants=["tenant_1"],
)
],
)
run_id = "00000000-0000-0000-0000-000000000999"
# 1. Insert a pending scoring run
async with session_factory() as session:
session.add(
ScoringRun(
id=run_id,
tenant_id="tenant_1",
subject_ref="lead_999",
scoring_config_version="1.0.0",
idempotency_key="idem-999",
payload_hash="hash-999",
status=ScoringRunStatus.PENDING.value,
snapshot_data={
"version": "1.0.0",
"captured_at": "2026-08-28T12:00:00Z",
"lead_profile": {
"ems_lead_id": "lead_999",
"name": "CEO",
},
},
effective_config=scoring_config.model_dump(mode="json"),
confidence_threshold=scoring_config.confidence_threshold,
created_at=now,
updated_at=now,
)
)
await session.commit()
# 2. Execute scoring run -> writes terminal outbox event atomically
executed = await execute_scoring_run(
session_factory=session_factory,
run_id=run_id,
tenant_vkey="test-vkey",
lease_seconds=30.0,
clock=lambda: now,
)
assert executed is True
# Verify outbox event written
async with session_factory() as session:
events = (
(await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_id)))
.scalars()
.all()
)
assert len(events) == 1
outbox_entry = events[0]
assert outbox_entry.event_type == "lead_scoring.run.terminal"
assert outbox_entry.delivery_status == OutboxDeliveryStatus.PENDING.value
# 3. Setup mock Omni subscriber that verifies HMAC signature and timestamp
received_requests = []
def mock_omni_receiver(request: httpx.Request):
event_id = request.headers["x-dotb-webhook-id"]
timestamp = request.headers["x-dotb-webhook-timestamp"]
signature = request.headers["x-dotb-webhook-signature"]
body = request.content
expected_sig = sign_webhook(
secret=TEST_SIGNING_SECRET,
event_id=event_id,
timestamp=timestamp,
body=body,
)
assert signature == expected_sig
parsed_body = json.loads(body.decode("utf-8"))
received_requests.append(parsed_body)
return httpx.Response(
200,
json={"status": "accepted", "event_id": event_id},
)
mock_client = httpx.AsyncClient(transport=httpx.MockTransport(mock_omni_receiver))
dispatcher = WebhookDispatcher(
session_factory=session_factory,
settings=settings,
http_client=mock_client,
clock=lambda: now,
)
# 4. Dispatch batch
dispatched_count = await dispatcher.dispatch_batch()
assert dispatched_count == 1
assert len(received_requests) == 1
assert received_requests[0]["tenant_id"] == "tenant_1"
assert received_requests[0]["subject_ref"] == "lead_999"
assert received_requests[0]["data"]["status"] == "completed"
assert received_requests[0]["data"]["rule_score"] == 85
# 5. Verify outbox marked delivered
async with session_factory() as session:
updated_event = (
await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_id))
).scalar_one()
assert updated_event.delivery_status == OutboxDeliveryStatus.DELIVERED.value
assert updated_event.delivered_at == now
assert updated_event.last_http_status == 200
await dispatcher.stop()
await mock_client.aclose()
@pytest.mark.asyncio
async def test_scoring_request_cannot_contain_callback_url(migrated_db):
session_factory, engine, db_url = migrated_db
from lead_scoring.config import ServiceCallerConfig
from lead_scoring.scoring import ScoringConfig
scoring_config = ScoringConfig(
version="1.0.0",
base_score=0,
rule_weight=1.0,
ai_weight=0.0,
confidence=0.9,
confidence_threshold=0.8,
hitl_policy_version="1.0.0",
rules=(),
)
settings = Settings(
database_url=db_url,
tenant_allowlist=["tenant_1"],
scoring_configs=[scoring_config],
service_callers=[
ServiceCallerConfig(
caller_id="test_caller",
token=SecretStr("valid_token"),
allowed_apps=["omni"],
allowed_features=["scoring"],
allowed_tenants=["tenant_1"],
)
],
)
app = create_app(settings=settings)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://testserver",
) as client:
headers = {
"Authorization": "Bearer valid_token",
"X-Tenant-Id": "tenant_1",
"Idempotency-Key": "test-idem-callback",
"X-DotB-App": "omni",
"X-DotB-Feature": "scoring",
"X-Tenant-VKey": "dummy-vkey",
}
# Attempt to inject callback_url in create request
payload_with_callback = {
"tenant_id": "tenant_1",
"subject_ref": "lead_123",
"scoring_config_version": "1.0.0",
"snapshot": {
"version": "1.0.0",
"captured_at": "2026-08-28T12:00:00Z",
"lead_profile": {
"ems_lead_id": "lead_123",
"custom_fields": {"course_interest": "DotB Course"},
},
},
"callback_url": "https://attacker.com/webhook",
}
resp = await client.post(
"/v1/scoring-runs", json=payload_with_callback, headers=headers
)
assert resp.status_code == 202
run_id = resp.json()["run_id"]
# Verify that in database, no callback_url is stored on scoring_runs
async with session_factory() as session:
run_row = await session.get(ScoringRun, run_id)
assert run_row is not None
assert "callback_url" not in run_row.snapshot_data
assert "callback_url" not in (run_row.effective_config or {})
@pytest.mark.asyncio
async def test_stale_claim_cannot_overwrite_newer_delivery_state(migrated_db):
session_factory, _, _ = migrated_db
now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC)
event_id = "00000000-0000-0000-0000-000000000777"
# Worker 1 claims the event with token_1
worker_1_token = "claim-token-worker-1"
async with session_factory() as session:
event = OutboxEvent(
id=event_id,
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id="run-777",
payload={"status": "completed"},
occurred_at=now,
delivery_status=OutboxDeliveryStatus.DELIVERING.value,
claim_token=worker_1_token,
lease_expires_at=now + timedelta(seconds=10),
attempt_count=0,
)
session.add(event)
await session.commit()
# Time passes, Worker 1 stalls. Worker 2 reclaims and marks delivered
async with session_factory() as session:
# Worker 2 successfully delivers with token_2
await session.execute(
update(OutboxEvent)
.where(OutboxEvent.id == event_id)
.values(
delivery_status=OutboxDeliveryStatus.DELIVERED.value,
delivered_at=now + timedelta(seconds=15),
claim_token=None,
lease_expires_at=None,
last_http_status=200,
)
)
await session.commit()
# Worker 1 wakes up and attempts to record a failure using stale token_1
stale_result = DeliveryResult(
classification=DeliveryClassification.DEAD_LETTER,
error_code="receiver_bad_request",
http_status=400,
detail="Stale worker error",
)
async with session_factory() as session:
recorded = await record_delivery_result(
session=session,
event=event,
result=stale_result,
max_attempts=5,
backoff_base_seconds=2.0,
backoff_max_seconds=60.0,
now=now + timedelta(seconds=20),
claim_token=worker_1_token,
)
await session.commit()
# Should return False due to claim fence mismatch
assert recorded is False
# Verify event remains DELIVERED and was NOT overwritten by stale worker
async with session_factory() as check_session:
updated = await check_session.get(OutboxEvent, event_id)
assert updated is not None
assert updated.delivery_status == OutboxDeliveryStatus.DELIVERED.value
assert updated.last_http_status == 200
assert updated.last_error_code is None
@pytest.mark.asyncio
async def test_webhook_max_attempts_setting_controls_retry_limit(migrated_db):
session_factory, _, _ = migrated_db
now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC)
event_id = "00000000-0000-0000-0000-000000000888"
token = "claim-token-max-attempts"
async with session_factory() as session:
event = OutboxEvent(
id=event_id,
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id="run-888",
payload={"status": "completed"},
occurred_at=now,
delivery_status=OutboxDeliveryStatus.DELIVERING.value,
claim_token=token,
attempt_count=2,
)
session.add(event)
await session.commit()
# Setting max_attempts = 2: attempt_count 2 reaches max_attempts -> DEAD_LETTER
retry_result = DeliveryResult(
classification=DeliveryClassification.RETRYABLE,
error_code="receiver_rate_limited",
http_status=429,
detail="Rate limited",
)
async with session_factory() as session:
recorded = await record_delivery_result(
session=session,
event=event,
result=retry_result,
max_attempts=2,
backoff_base_seconds=2.0,
backoff_max_seconds=60.0,
now=now,
claim_token=token,
)
await session.commit()
assert recorded is True
async with session_factory() as check_session:
updated = await check_session.get(OutboxEvent, event_id)
assert updated is not None
assert updated.delivery_status == OutboxDeliveryStatus.DEAD_LETTER.value
assert updated.attempt_count == 2
assert updated.last_error_code == "retries_exhausted"
@pytest.mark.asyncio
async def test_dispatch_non_200_2xx_classified_as_dlq():
now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC)
for status_code in [201, 202, 204]:
event = OutboxEvent(
id=f"00000000-0000-0000-0000-000000000{status_code}",
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id=f"run-{status_code}",
payload={"status": "completed"},
occurred_at=now,
delivery_status=OutboxDeliveryStatus.DELIVERING.value,
)
mock_transport = httpx.MockTransport(
lambda request, sc=status_code: httpx.Response(sc, text="Non-200 2xx response")
)
async with httpx.AsyncClient(transport=mock_transport) as client:
result = await dispatch_single_event(
event=event,
target_url="http://localhost:8080/api/v1/internal/lead-scoring/events",
signing_key_id="test-key",
signing_secret=TEST_SIGNING_SECRET,
timeout_seconds=5.0,
http_client=client,
now=now,
)
assert result.classification == DeliveryClassification.DEAD_LETTER
assert result.http_status == status_code
assert result.error_code == "invalid_receiver_response"
@pytest.mark.asyncio
async def test_crash_reclaims_are_bounded_and_eventually_dlq(migrated_db):
session_factory, _, _ = migrated_db
t0 = datetime(2026, 8, 28, 12, 20, tzinfo=UTC)
event_id = "00000000-0000-0000-0000-000000001201"
expected_max_attempts = 3
event = OutboxEvent(
id=event_id,
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id="run-crash-test",
payload={
"event_id": event_id,
"event_type": "lead_scoring.run.terminal",
"event_version": "1.0.0",
"tenant_id": "tenant-1",
"subject_ref": "lead-1",
"scoring_config_version": "1.0.0",
"run_id": event_id,
"run_created_at": t0.isoformat().replace("+00:00", "Z"),
"occurred_at": (t0 + timedelta(seconds=1)).isoformat().replace("+00:00", "Z"),
"data": {"status": "completed", "rule_score": 80},
},
occurred_at=t0,
)
async with session_factory() as session:
session.add(event)
await session.commit()
# Simulate 5 claim/crash cycles (process death before result recording)
for index in range(5):
async with session_factory() as session:
await claim_outbox_events(
session,
batch_size=1,
lease_seconds=1,
now=t0 + timedelta(seconds=index * 2),
max_attempts=expected_max_attempts,
)
await session.commit()
async with session_factory() as session:
stored = await session.get(OutboxEvent, event_id)
assert stored is not None
assert stored.delivery_status == OutboxDeliveryStatus.DEAD_LETTER.value
assert stored.attempt_count >= expected_max_attempts
assert stored.last_error_code == "retries_exhausted"
assert stored.next_attempt_at is None
assert stored.claim_token is None
assert stored.lease_expires_at is None
@pytest.mark.asyncio
async def test_retry_followed_by_success_clears_next_attempt_at_and_errors(migrated_db):
session_factory, _, _ = migrated_db
t0 = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC)
event_id = "00000000-0000-0000-0000-000000000654"
# Step 1: Initial event in PENDING state
event = OutboxEvent(
id=event_id,
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id="run-retry-then-success",
payload={"status": "completed"},
occurred_at=t0,
delivery_status=OutboxDeliveryStatus.PENDING.value,
)
async with session_factory() as session:
session.add(event)
await session.commit()
# Step 2: Claim event (Attempt 1)
async with session_factory() as session:
claimed = await claim_outbox_events(
session=session,
batch_size=1,
lease_seconds=30.0,
now=t0,
max_attempts=5,
)
await session.commit()
assert len(claimed) == 1
event_claimed = claimed[0]
assert event_claimed.attempt_count == 1
token_1 = event_claimed.claim_token
# Step 3: Attempt 1 fails with 429 Retryable
retry_result = DeliveryResult(
classification=DeliveryClassification.RETRYABLE,
http_status=429,
error_code="receiver_rate_limited",
detail="Rate limited",
)
async with session_factory() as session:
recorded = await record_delivery_result(
session=session,
event=event_claimed,
result=retry_result,
max_attempts=5,
backoff_base_seconds=2.0,
backoff_max_seconds=60.0,
now=t0,
claim_token=token_1,
)
await session.commit()
assert recorded is True
# Check that next_attempt_at is set to t0 + 2s
async with session_factory() as session:
ev_after_retry = await session.get(OutboxEvent, event_id)
assert ev_after_retry is not None
assert ev_after_retry.delivery_status == OutboxDeliveryStatus.PENDING.value
assert ev_after_retry.next_attempt_at == t0 + timedelta(seconds=2.0)
assert ev_after_retry.last_error_code == "receiver_rate_limited"
# Step 4: Claim event at next_attempt_at (Attempt 2)
t1 = t0 + timedelta(seconds=2.0)
async with session_factory() as session:
claimed_2 = await claim_outbox_events(
session=session,
batch_size=1,
lease_seconds=30.0,
now=t1,
max_attempts=5,
)
await session.commit()
assert len(claimed_2) == 1
event_claimed_2 = claimed_2[0]
assert event_claimed_2.attempt_count == 2
token_2 = event_claimed_2.claim_token
# Step 5: Attempt 2 succeeds with HTTP 200 DELIVERED
success_result = DeliveryResult(
classification=DeliveryClassification.DELIVERED,
http_status=200,
ingest_status="accepted",
)
async with session_factory() as session:
recorded_2 = await record_delivery_result(
session=session,
event=event_claimed_2,
result=success_result,
max_attempts=5,
backoff_base_seconds=2.0,
backoff_max_seconds=60.0,
now=t1,
claim_token=token_2,
)
await session.commit()
assert recorded_2 is True
# Step 6: Verify observable state: DELIVERED, next_attempt_at is None, error metadata cleared
async with session_factory() as session:
ev_final = await session.get(OutboxEvent, event_id)
assert ev_final is not None
assert ev_final.delivery_status == OutboxDeliveryStatus.DELIVERED.value
assert ev_final.next_attempt_at is None
assert ev_final.lease_expires_at is None
assert ev_final.claim_token is None
assert ev_final.last_error_code is None
assert ev_final.last_error_detail is None
assert ev_final.delivered_at == t1
assert ev_final.last_http_status == 200
@pytest.mark.asyncio
async def test_slow_first_item_does_not_cause_lease_exhaustion_for_subsequent_items(migrated_db):
session_factory, _, _ = migrated_db
t0 = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC)
event_1_id = "00000000-0000-0000-0000-000000000901"
event_2_id = "00000000-0000-0000-0000-000000000902"
async with session_factory() as session:
session.add(
OutboxEvent(
id=event_1_id,
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id="run-slow-1",
payload={"status": "completed"},
occurred_at=t0,
delivery_status=OutboxDeliveryStatus.PENDING.value,
)
)
session.add(
OutboxEvent(
id=event_2_id,
event_type="lead_scoring.run.terminal",
event_version="1.0.0",
run_id="run-slow-2",
payload={"status": "completed"},
occurred_at=t0 + timedelta(seconds=1),
delivery_status=OutboxDeliveryStatus.PENDING.value,
)
)
await session.commit()
simulated_time = t0
def current_time() -> datetime:
return simulated_time
settings = Settings(
webhook_subscriber_url="http://localhost:8080/api/v1/internal/lead-scoring/events",
webhook_signing_secret=SecretStr(TEST_SIGNING_SECRET),
webhook_signing_key_id="test-key",
webhook_lease_seconds=5.0,
webhook_max_attempts=2,
webhook_batch_size=2,
)
# Mock receiver where first item takes 10s (exceeding lease 5s of first item)
def handle_request(request: httpx.Request) -> httpx.Response:
nonlocal simulated_time
body = json.loads(request.content.decode("utf-8"))
if body.get("event_id") == event_1_id:
# Advance time by 10s during dispatch of item 1
simulated_time += timedelta(seconds=10.0)
return httpx.Response(
200,
json={"status": "accepted", "event_id": request.headers.get("x-dotb-webhook-id", "")},
)
mock_client = httpx.AsyncClient(transport=httpx.MockTransport(handle_request))
dispatcher = WebhookDispatcher(
session_factory=session_factory,
settings=settings,
http_client=mock_client,
clock=current_time,
)
processed = await dispatcher.dispatch_batch()
assert processed == 2
# Verify both events delivered successfully without premature exhaustion
async with session_factory() as session:
ev1 = await session.get(OutboxEvent, event_1_id)
assert ev1 is not None
assert ev1.delivery_status == OutboxDeliveryStatus.DELIVERED.value
assert ev1.attempt_count == 1
ev2 = await session.get(OutboxEvent, event_2_id)
assert ev2 is not None
assert ev2.delivery_status == OutboxDeliveryStatus.DELIVERED.value
# Item 2 was claimed at t0 + 10s, attempt_count was 1 (not reclaimed/exhausted)
assert ev2.attempt_count == 1
assert ev2.next_attempt_at is None
await dispatcher.stop()
await mock_client.aclose()