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