dotb_ai-platform/tests/test_score_endpoint.py
hungtran24 b1f6643796 refactor: score synchronously and drop webhook/background execution
POST /v1/lead-scoring/score now evaluates in the request, applies an overall
AI deadline, and stores the completed run only after scoring. Idempotency is
kept by (app_id, account_id, request_id) with a body fingerprint.

Removes the webhook outbox, scheduler/recovery loop, app lead_scoring
permission, credential audit events, seed_demo and generate_secret scripts,
and the tests that covered them. Trims tests to behavior of the new flow.
2026-10-09 02:44:24 +07:00

191 lines
7.2 KiB
Python

import asyncio
import json
import threading
from pathlib import Path
from uuid import uuid4
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import func, select
from ai_platform.infrastructure.persistence.models import ScoringRun
FIXTURE = Path(__file__).parent / "fixtures" / "mock_omni_run.json"
URL = "/v1/lead-scoring/score"
def _completion(content: dict) -> httpx.Response:
return httpx.Response(
200, json={"choices": [{"message": {"content": json.dumps(content, ensure_ascii=False)}}]}
)
def _ai_handler(calls: list[httpx.Request]):
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
if "Trích xuất bằng chứng" in json.loads(request.content)["messages"][1]["content"]:
item = {"signal": "ý định", "detail": "Hỏi hồ sơ.", "source": "mock-message-003"}
return _completion({"items": [item]})
return _completion({"score": 40, "confidence": 0.9, "reasoning": "Nhu cầu rõ ràng."})
return handler
def _payload(*, rule_weight: float = 1, ai_weight: float = 0) -> dict:
payload = json.loads(FIXTURE.read_text())
payload["request_id"] = str(uuid4())
payload["scoring_config"].update(rule_weight=rule_weight, ai_weight=ai_weight)
return payload
def _post(client: TestClient, env: dict, payload: dict, *, vkey: bool = False, **headers):
all_headers = {
"Authorization": f"Bearer {env['app_key']}",
"Idempotency-Key": payload["request_id"],
**({"X-Tenant-VKey": env["vkey"]} if vkey else {}),
**headers,
}
return client.post(URL, headers=all_headers, json=payload)
def _row_count(env: dict, request_id: str) -> int:
async def count() -> int:
async with env["factory"]() as db:
total = await db.scalar(
select(func.count())
.select_from(ScoringRun)
.where(ScoringRun.request_id == request_id)
)
await env["factory"].kw["bind"].dispose()
return total or 0
return asyncio.run(count())
def test_rules_only_returns_completed_result_without_calling_ai(aihub_test_environment: dict):
calls: list[httpx.Request] = []
env = aihub_test_environment
with TestClient(env["app_for"](_ai_handler(calls))) as client:
response = _post(client, env, _payload())
body = response.json()
assert response.status_code == 200
assert (body["status"], body["result_mode"], body["error_code"]) == (
"completed",
"rules_only",
None,
)
assert body["final_score"] == body["rule_score"] == 80
assert body["metrics"]["message_count"] == 5
assert not calls
def test_hybrid_scores_in_one_request_and_never_stores_or_logs_the_ai_key(
aihub_test_environment: dict, caplog: pytest.LogCaptureFixture
):
calls: list[httpx.Request] = []
env = aihub_test_environment
payload = _payload(rule_weight=0.25, ai_weight=0.75)
with TestClient(env["app_for"](_ai_handler(calls))) as client:
response = _post(client, env, payload, vkey=True)
body = response.json()
assert response.status_code == 200
assert (body["result_mode"], body["ai_score"], body["final_score"]) == ("hybrid", 40, 50)
assert body["ai_evidence"][0]["source"] == "mock-message-003"
assert len(calls) == 2
assert all(c.headers["authorization"] == f"Bearer {env['vkey']}" for c in calls)
async def stored() -> str:
async with env["factory"]() as db:
row = await db.scalar(select(ScoringRun).where(ScoringRun.id == body["run_id"]))
await env["factory"].kw["bind"].dispose()
return json.dumps([row.request, row.ai_evidence, row.ai_reasoning])
assert env["vkey"] not in response.text + asyncio.run(stored()) + caplog.text
assert env["app_key"] not in response.text + caplog.text
@pytest.mark.parametrize(
("vkey", "handler", "error_code"),
[
(False, lambda r: httpx.Response(500), "vkey_missing"),
(True, lambda r: httpx.Response(500), "aihub_http_error"),
(True, lambda r: _completion({"items": "bad"}), "aihub_invalid_evidence"),
],
)
def test_ai_failure_degrades_to_rule_score(aihub_test_environment: dict, vkey, handler, error_code):
env = aihub_test_environment
with TestClient(env["app_for"](handler)) as client:
response = _post(client, env, _payload(rule_weight=0.5, ai_weight=0.5), vkey=vkey)
body = response.json()
assert response.status_code == 200
assert (body["result_mode"], body["error_code"]) == ("rules_only_fallback", error_code)
assert body["final_score"] == body["rule_score"] == 80
assert body["ai_score"] is None
def test_same_key_replays_the_stored_result_without_calling_ai_again(
aihub_test_environment: dict,
):
calls: list[httpx.Request] = []
env = aihub_test_environment
payload = _payload(rule_weight=0.5, ai_weight=0.5)
with TestClient(env["app_for"](_ai_handler(calls))) as client:
first = _post(client, env, payload, vkey=True)
again = _post(client, env, payload, vkey=True)
assert first.status_code == again.status_code == 200
assert again.json()["run_id"] == first.json()["run_id"]
assert len(calls) == 2
def test_same_key_with_a_different_body_is_422(aihub_test_environment: dict):
env = aihub_test_environment
payload = _payload()
changed = {
**payload,
"scoring_config": {**payload["scoring_config"], "rule_weight": 0.9, "ai_weight": 0.1},
}
with TestClient(env["app_for"](_ai_handler([]))) as client:
assert _post(client, env, payload).status_code == 200
response = _post(client, env, changed)
assert response.status_code == 422
def test_concurrent_requests_with_one_key_store_one_run(aihub_test_environment: dict):
env = aihub_test_environment
payload = _payload(rule_weight=0.5, ai_weight=0.5)
statuses: list[int] = []
def send() -> None:
with TestClient(env["app_for"](_ai_handler([]))) as client:
statuses.append(_post(client, env, payload, vkey=True).status_code)
threads = [threading.Thread(target=send) for _ in range(2)]
[t.start() for t in threads]
[t.join() for t in threads]
assert statuses == [200, 200]
assert _row_count(env, payload["request_id"]) == 1
def test_authentication_and_validation_errors(aihub_test_environment: dict):
env = aihub_test_environment
payload = _payload()
with TestClient(env["app_for"](_ai_handler([]))) as client:
no_key = client.post(URL, headers={"Idempotency-Key": payload["request_id"]}, json=payload)
bad_key = _post(client, env, payload, Authorization="Bearer wrong")
invalid = _post(client, env, {**payload, "account_id": ""})
long_vkey = client.post(
URL,
headers={
"Authorization": f"Bearer {env['app_key']}",
"Idempotency-Key": _payload()["request_id"],
"X-Tenant-VKey": "k" * 5_000,
},
json=_payload(),
)
assert (no_key.status_code, bad_key.status_code) == (401, 401)
assert invalid.status_code == 422
assert all(set(error) == {"loc", "msg"} for error in invalid.json()["detail"])
assert long_vkey.status_code == 422
assert _row_count(env, payload["request_id"]) == 0