Add app credentials, account-scoped idempotency and per-app terminal notifications. Consolidate the unreleased migration baseline, preserve rule descriptions as AI context, and regenerate provider contracts with regression coverage.
173 lines
5.8 KiB
Python
173 lines
5.8 KiB
Python
from importlib.util import module_from_spec, spec_from_file_location
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from ai_platform.infrastructure.config.settings import Settings
|
|
from ai_platform.presentation.http.schemas import RunRequest
|
|
|
|
SEED_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "seed_demo.py"
|
|
SEED_SPEC = spec_from_file_location("seed_demo_script", SEED_SCRIPT)
|
|
assert SEED_SPEC is not None and SEED_SPEC.loader is not None
|
|
seed_demo = module_from_spec(SEED_SPEC)
|
|
SEED_SPEC.loader.exec_module(seed_demo)
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(self, status_code: int, body: dict[str, Any]):
|
|
self.status_code = status_code
|
|
self._body = body
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
return self._body
|
|
|
|
|
|
class FakeClient:
|
|
def __init__(self, responses: list[FakeResponse]):
|
|
self.responses = responses
|
|
self.calls: list[tuple[str, str, dict[str, Any]]] = []
|
|
|
|
def post(self, path: str, **kwargs: Any) -> FakeResponse:
|
|
self.calls.append(("POST", path, kwargs))
|
|
return self.responses.pop(0)
|
|
|
|
def get(self, path: str, **kwargs: Any) -> FakeResponse:
|
|
self.calls.append(("GET", path, kwargs))
|
|
return self.responses.pop(0)
|
|
|
|
|
|
def settings() -> Settings:
|
|
return Settings(
|
|
environment="development",
|
|
database_url="postgresql+asyncpg://local@127.0.0.1:55432/ai_platform",
|
|
)
|
|
|
|
|
|
def complete_run_response(run_id: str, payload: dict[str, Any]) -> FakeResponse:
|
|
request = RunRequest.model_validate(payload).model_dump(mode="json")
|
|
return FakeResponse(
|
|
200,
|
|
{
|
|
"run_id": run_id,
|
|
"status": "completed",
|
|
"rule_score": 80,
|
|
"ai_score": None,
|
|
"final_score": 80,
|
|
"degraded": False,
|
|
"request": {"snapshot": request["snapshot"]},
|
|
},
|
|
)
|
|
|
|
|
|
def test_fixture_has_realistic_conversations_and_metrics_rules():
|
|
payload = seed_demo.load_payload()
|
|
|
|
assert len(payload["snapshot"]["conversations"]) == 2
|
|
assert sum(len(c["messages"]) for c in payload["snapshot"]["conversations"]) == 5
|
|
rules = payload["scoring_config"]["rules"]
|
|
assert {rule["field"] for rule in rules} >= {
|
|
"metrics.conversation_count",
|
|
"metrics.contact_message_count",
|
|
}
|
|
assert sum(rule["points"] for rule in rules) == 80
|
|
assert payload["scoring_config"]["ai_weight"] == 0
|
|
|
|
|
|
def test_seed_creates_one_run_and_reads_persisted_conversation_snapshot():
|
|
payload = seed_demo.load_payload()
|
|
run_id = "123e4567-e89b-12d3-a456-426614174000"
|
|
client = FakeClient(
|
|
[
|
|
FakeResponse(202, {"run_id": run_id}),
|
|
complete_run_response(run_id, payload),
|
|
]
|
|
)
|
|
|
|
actual_id, result = seed_demo.seed_demo(client, settings(), "test-app-key")
|
|
|
|
assert actual_id == run_id
|
|
assert result["status"] == "completed"
|
|
assert result["ai_score"] is None
|
|
assert [call[1] for call in client.calls] == [
|
|
"/v1/lead-scoring/runs",
|
|
f"/v1/lead-scoring/runs/{run_id}",
|
|
]
|
|
assert client.calls[0][2]["json"] == payload
|
|
assert client.calls[0][2]["headers"]["Idempotency-Key"] == payload["request_id"]
|
|
assert client.calls[1][2]["headers"]["Authorization"] == "Bearer test-app-key"
|
|
|
|
|
|
def test_missing_app_key_stops_without_submitting_a_run():
|
|
client = FakeClient([])
|
|
with pytest.raises(SystemExit, match="operator-issued app key"):
|
|
seed_demo.seed_demo(client, settings())
|
|
assert client.calls == []
|
|
|
|
|
|
def test_existing_app_key_accepts_idempotent_replay():
|
|
payload = seed_demo.load_payload()
|
|
run_id = "123e4567-e89b-12d3-a456-426614174000"
|
|
client = FakeClient(
|
|
[
|
|
FakeResponse(200, {"run_id": run_id}),
|
|
complete_run_response(run_id, payload),
|
|
]
|
|
)
|
|
|
|
actual_id, _result = seed_demo.seed_demo(client, settings(), "existing-app-secret")
|
|
|
|
assert actual_id == run_id
|
|
assert [call[1] for call in client.calls] == [
|
|
"/v1/lead-scoring/runs",
|
|
f"/v1/lead-scoring/runs/{run_id}",
|
|
]
|
|
assert client.calls[0][2]["headers"]["Authorization"] == "Bearer existing-app-secret"
|
|
|
|
|
|
def test_seed_rejects_readback_without_persisted_snapshot():
|
|
run_id = "123e4567-e89b-12d3-a456-426614174000"
|
|
client = FakeClient(
|
|
[
|
|
FakeResponse(202, {"run_id": run_id}),
|
|
FakeResponse(
|
|
200,
|
|
{
|
|
"run_id": run_id,
|
|
"status": "completed",
|
|
"ai_score": None,
|
|
"degraded": False,
|
|
},
|
|
),
|
|
]
|
|
)
|
|
|
|
with pytest.raises(SystemExit, match="did not return the persisted conversation snapshot"):
|
|
seed_demo.seed_demo(client, settings(), "existing-app-secret")
|
|
|
|
|
|
def test_seed_refuses_non_development_or_nonlocal_database_names():
|
|
with pytest.raises(SystemExit, match="must be development"):
|
|
seed_demo.validate_local_target(Settings(environment="production"))
|
|
with pytest.raises(SystemExit, match="API target must use the local"):
|
|
seed_demo.validate_local_target(
|
|
Settings(
|
|
environment="development",
|
|
database_url="postgresql+asyncpg://local@127.0.0.1:55432/ai_platform_test",
|
|
)
|
|
)
|
|
with pytest.raises(SystemExit, match="API target must use the local"):
|
|
seed_demo.validate_local_target(
|
|
Settings(
|
|
environment="development",
|
|
database_url="postgresql+asyncpg://local@remote.example:5432/ai_platform",
|
|
)
|
|
)
|
|
with pytest.raises(SystemExit, match="API target must use the local"):
|
|
seed_demo.validate_local_target(
|
|
Settings(
|
|
environment="development",
|
|
database_url="postgresql+asyncpg://local@db:5432/customer_data",
|
|
)
|
|
)
|