Files
Edu/services/ai/tests/test_coverage_gaps.py
2026-07-10 18:57:39 +08:00

743 lines
25 KiB
Python

"""Coverage gap tests for base_client, gRPC clients, usage, rate_limiter, etc.
Fills coverage gaps identified in:
- clients/base_client.py (BaseGrpcClient, LoggingInterceptor, TracingInterceptor)
- clients/content_client.py (ContentClientGrpc impl)
- clients/data_ana_client.py (DataAnaClientGrpc impl)
- clients/iam_client.py (IamClientGrpc impl)
- usage/usage_recorder.py (Redis paths)
- usage/kafka_producer.py (start/stop/publish paths)
- rate_limiter.py (Redis paths)
- middleware/permission.py (require_permission decorator)
- middleware/error_handler.py (grpc_error_mapper)
- workflow/state_store.py (Redis paths)
- config.py (Settings properties)
"""
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import grpc
import pytest
from redis.exceptions import RedisError
from src.ai.clients.base_client import BaseGrpcClient, LoggingInterceptor, TracingInterceptor
from src.ai.clients.content_client import ContentClientGrpc, QuestionInput
from src.ai.clients.data_ana_client import DataAnaClientGrpc
from src.ai.clients.iam_client import IamClientGrpc
from src.ai.config import Settings
from src.ai.errors import AIError, AIRateLimitedError, ErrorCode
from src.ai.middleware.auth import UserContext
from src.ai.middleware.error_handler import grpc_error_mapper
from src.ai.middleware.permission import (
PERMISSION_AI_CHAT,
PERMISSION_AI_LESSON_GENERATE,
PermissionGuard,
require_permission,
)
from src.ai.rate_limiter import RateLimiter
from src.ai.usage.kafka_producer import KafkaProducer, UsageEvent
from src.ai.usage.usage_recorder import UsageRecord, UsageRecorder
from src.ai.workflow.state_store import WorkflowState, WorkflowStateStore
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _ConcreteClient(BaseGrpcClient):
"""Concrete subclass for testing abstract BaseGrpcClient."""
def is_available(self) -> bool:
return self._channel is not None
class _AsyncCM:
"""Minimal async context manager for mocking Kafka transaction()."""
async def __aenter__(self) -> "_AsyncCM":
return self
async def __aexit__(self, *args: object) -> None:
pass
def _make_redis_pipeline_mock() -> tuple[AsyncMock, MagicMock]:
"""Build a (redis, pipeline) mock pair for UsageRecorder pipeline tests."""
mock_redis: AsyncMock = AsyncMock()
mock_pipe: MagicMock = MagicMock()
mock_pipe.incrby = MagicMock(return_value=mock_pipe)
mock_pipe.expire = MagicMock(return_value=mock_pipe)
mock_pipe.execute = AsyncMock(return_value=[])
mock_redis.pipeline = MagicMock(return_value=mock_pipe)
return mock_redis, mock_pipe
# ---------------------------------------------------------------------------
# base_client.py — BaseGrpcClient
# ---------------------------------------------------------------------------
async def test_base_client_connect() -> None:
with patch("grpc.aio.insecure_channel") as mock_fn:
mock_channel = AsyncMock()
mock_fn.return_value = mock_channel
client = _ConcreteClient("localhost:50054")
await client.connect()
mock_fn.assert_called_once()
assert client._channel is mock_channel
async def test_base_client_connect_idempotent() -> None:
with patch("grpc.aio.insecure_channel") as mock_fn:
mock_fn.return_value = AsyncMock()
client = _ConcreteClient("localhost:50054")
await client.connect()
await client.connect()
mock_fn.assert_called_once()
async def test_base_client_close() -> None:
with patch("grpc.aio.insecure_channel") as mock_fn:
mock_channel = AsyncMock()
mock_channel.close = AsyncMock()
mock_fn.return_value = mock_channel
client = _ConcreteClient("localhost:50054")
await client.connect()
await client.close()
assert client._channel is None
mock_channel.close.assert_called_once()
async def test_base_client_close_not_connected() -> None:
client = _ConcreteClient("localhost:50054")
await client.close()
assert client._channel is None
def test_base_client_channel_not_connected_raises() -> None:
client = _ConcreteClient("localhost:50054")
with pytest.raises(RuntimeError, match="not connected"):
_ = client.channel
# ---------------------------------------------------------------------------
# base_client.py — TracingInterceptor
# ---------------------------------------------------------------------------
def test_tracing_interceptor_inject_metadata() -> None:
interceptor = TracingInterceptor(request_id="req-123")
result = interceptor._inject_metadata([])
assert ("x-request-id", "req-123") in result
assert any(k == "traceparent" for k, _ in result)
def test_tracing_interceptor_no_request_id() -> None:
interceptor = TracingInterceptor(request_id="")
result = interceptor._inject_metadata([])
assert result == []
def test_tracing_interceptor_with_existing_metadata() -> None:
interceptor = TracingInterceptor(request_id="req-456")
result = interceptor._inject_metadata([("existing", "value")])
assert ("existing", "value") in result
assert ("x-request-id", "req-456") in result
assert any(k == "traceparent" for k, _ in result)
def test_tracing_interceptor_with_none_metadata() -> None:
interceptor = TracingInterceptor(request_id="req-789")
result = interceptor._inject_metadata(None)
assert isinstance(result, list)
assert ("x-request-id", "req-789") in result
# ---------------------------------------------------------------------------
# base_client.py — LoggingInterceptor
# ---------------------------------------------------------------------------
async def test_logging_interceptor_unary_success() -> None:
interceptor = LoggingInterceptor()
continuation = AsyncMock(return_value="response")
call_details = MagicMock()
call_details.method = "/svc/method"
result = await interceptor.intercept_unary_unary(continuation, call_details, "req")
assert result == "response"
continuation.assert_called_once()
async def test_logging_interceptor_unary_error_reraises() -> None:
interceptor = LoggingInterceptor()
rpc_error = grpc.aio.AioRpcError(
code=grpc.StatusCode.UNAVAILABLE,
initial_metadata=[],
trailing_metadata=[],
details="service unavailable",
)
continuation = AsyncMock(side_effect=rpc_error)
call_details = MagicMock()
call_details.method = "/svc/method"
with pytest.raises(grpc.aio.AioRpcError):
await interceptor.intercept_unary_unary(continuation, call_details, "req")
async def test_logging_interceptor_unary_stream_success() -> None:
interceptor = LoggingInterceptor()
async def _gen() -> Any:
yield "chunk1"
yield "chunk2"
async def continuation(cd: Any, req: Any) -> Any:
return _gen()
call_details = MagicMock()
call_details.method = "/svc/stream"
agen = interceptor.intercept_unary_stream(continuation, call_details, "req")
chunks = [c async for c in agen]
assert chunks == ["chunk1", "chunk2"]
# ---------------------------------------------------------------------------
# UsageRecorder — Redis paths
# ---------------------------------------------------------------------------
async def test_record_with_redis() -> None:
mock_redis, mock_pipe = _make_redis_pipeline_mock()
recorder = UsageRecorder(redis=mock_redis)
record = UsageRecord(
user_id="u-1",
school_id="s-1",
provider="openai",
model="gpt-4o",
operation="chat",
total_tokens=100,
)
await recorder.record(record)
assert mock_pipe.incrby.call_count == 2
assert mock_pipe.expire.call_count == 2
mock_pipe.execute.assert_called_once()
async def test_record_redis_error_degraded() -> None:
mock_redis, mock_pipe = _make_redis_pipeline_mock()
mock_pipe.execute = AsyncMock(side_effect=RedisError("conn refused"))
recorder = UsageRecorder(redis=mock_redis)
record = UsageRecord(
user_id="u-1",
school_id="s-1",
provider="openai",
model="gpt-4o",
operation="chat",
total_tokens=100,
)
await recorder.record(record)
async def test_get_user_usage_with_redis() -> None:
mock_redis = AsyncMock()
mock_redis.get = AsyncMock(return_value=b"500")
recorder = UsageRecorder(redis=mock_redis)
usage = await recorder.get_user_usage("u-1")
assert usage == 500
async def test_get_user_usage_redis_error_returns_0() -> None:
mock_redis = AsyncMock()
mock_redis.get = AsyncMock(side_effect=RedisError("conn"))
recorder = UsageRecorder(redis=mock_redis)
usage = await recorder.get_user_usage("u-1")
assert usage == 0
async def test_get_school_usage_with_redis() -> None:
mock_redis = AsyncMock()
mock_redis.get = AsyncMock(return_value=b"2000")
recorder = UsageRecorder(redis=mock_redis)
usage = await recorder.get_school_usage("s-1")
assert usage == 2000
async def test_get_school_usage_no_redis_returns_0() -> None:
recorder = UsageRecorder(redis=None)
usage = await recorder.get_school_usage("s-1")
assert usage == 0
# ---------------------------------------------------------------------------
# RateLimiter — Redis paths
# ---------------------------------------------------------------------------
async def test_check_with_redis_allows() -> None:
mock_redis = AsyncMock()
mock_redis.script_load = AsyncMock(return_value="sha-abc")
mock_redis.evalsha = AsyncMock(return_value=[1, 9])
limiter = RateLimiter(redis=mock_redis)
results = await limiter.check(user_id="u-1")
assert len(results) == 1
assert results[0].allowed is True
assert results[0].remaining == 9
async def test_check_with_redis_blocks() -> None:
mock_redis = AsyncMock()
mock_redis.script_load = AsyncMock(return_value="sha-abc")
mock_redis.evalsha = AsyncMock(return_value=[0, 0])
limiter = RateLimiter(redis=mock_redis)
with pytest.raises(AIRateLimitedError):
await limiter.check(user_id="u-1")
async def test_check_redis_error_degraded_allows() -> None:
mock_redis = AsyncMock()
mock_redis.script_load = AsyncMock(return_value="sha-abc")
mock_redis.evalsha = AsyncMock(side_effect=RedisError("eval failed"))
limiter = RateLimiter(redis=mock_redis)
results = await limiter.check(user_id="u-1")
assert len(results) == 1
assert results[0].allowed is True
assert results[0].remaining == 10
async def test_lua_load_failure_degrades() -> None:
mock_redis = AsyncMock()
mock_redis.script_load = AsyncMock(side_effect=RedisError("load failed"))
limiter = RateLimiter(redis=mock_redis)
results = await limiter.check(user_id="u-1", ip="1.2.3.4")
assert len(results) == 2
assert all(r.allowed for r in results)
# ---------------------------------------------------------------------------
# KafkaProducer — start / stop / publish paths
# ---------------------------------------------------------------------------
async def test_start_success() -> None:
mock_module = MagicMock()
mock_producer_cls = MagicMock()
mock_instance = AsyncMock()
mock_instance.start = AsyncMock()
mock_producer_cls.return_value = mock_instance
mock_module.AIOKafkaProducer = mock_producer_cls
with patch.dict("sys.modules", {"aiokafka": mock_module}):
producer = KafkaProducer()
await producer.start()
assert producer.is_started is True
mock_producer_cls.assert_called_once()
async def test_start_failure_degraded() -> None:
with patch.dict("sys.modules", {"aiokafka": None}):
producer = KafkaProducer()
await producer.start()
assert producer.is_started is False
assert producer._producer is None
async def test_stop_when_started() -> None:
mock_prod = AsyncMock()
mock_prod.stop = AsyncMock()
producer = KafkaProducer()
producer._producer = mock_prod
producer._started = True
await producer.stop()
assert producer.is_started is False
assert producer._producer is None
mock_prod.stop.assert_called_once()
async def test_stop_when_not_started() -> None:
producer = KafkaProducer()
await producer.stop()
assert producer.is_started is False
async def test_stop_failure_degraded() -> None:
mock_prod = AsyncMock()
mock_prod.stop = AsyncMock(side_effect=RuntimeError("stop failed"))
producer = KafkaProducer()
producer._producer = mock_prod
producer._started = True
await producer.stop()
assert producer.is_started is False
assert producer._producer is None
async def test_publish_not_started_skipped() -> None:
producer = KafkaProducer()
event = UsageEvent(user_id="u-1")
await producer.publish(event)
async def test_publish_success() -> None:
mock_prod = AsyncMock()
mock_prod.transaction = MagicMock(return_value=_AsyncCM())
mock_prod.send_and_wait = AsyncMock()
producer = KafkaProducer()
producer._producer = mock_prod
producer._started = True
event = UsageEvent(user_id="u-1", operation="chat", event_id="e-1")
await producer.publish(event)
mock_prod.send_and_wait.assert_called_once()
async def test_publish_failure_degraded() -> None:
mock_prod = AsyncMock()
mock_prod.transaction = MagicMock(return_value=_AsyncCM())
mock_prod.send_and_wait = AsyncMock(side_effect=RuntimeError("send failed"))
producer = KafkaProducer()
producer._producer = mock_prod
producer._started = True
event = UsageEvent(user_id="u-1", event_id="e-2")
await producer.publish(event)
# ---------------------------------------------------------------------------
# ContentClientGrpc
# ---------------------------------------------------------------------------
async def test_content_grpc_connect_close() -> None:
with patch("grpc.aio.insecure_channel") as mock_fn:
mock_channel = AsyncMock()
mock_channel.close = AsyncMock()
mock_fn.return_value = mock_channel
client = ContentClientGrpc()
assert client.is_available() is False
await client.connect()
assert client.is_available() is True
await client.close()
assert client.is_available() is False
mock_channel.close.assert_called_once()
async def test_content_grpc_methods_not_connected() -> None:
client = ContentClientGrpc()
assert client.is_available() is False
pre = await client.get_prerequisites("kp-1")
assert len(pre) == 1
path = await client.get_learning_path("s-1", "math")
assert len(path) == 3
questions = [
QuestionInput(
question="q1",
answer="a1",
explanation="e1",
question_type="short_answer",
difficulty="easy",
knowledge_point_ids=["kp-1"],
),
]
result = await client.create_questions(questions, user_id="u-1")
assert len(result) == 1
async def test_content_grpc_methods_connected() -> None:
client = ContentClientGrpc()
client._channel = MagicMock()
assert client.is_available() is True
pre = await client.get_prerequisites("kp-1")
assert len(pre) == 1
path = await client.get_learning_path("s-1", "math")
assert len(path) == 3
questions = [
QuestionInput(
question="q1",
answer="a1",
explanation="e1",
question_type="short_answer",
difficulty="easy",
knowledge_point_ids=["kp-1"],
),
]
result = await client.create_questions(questions, user_id="u-1")
assert len(result) == 1
async def test_content_grpc_create_questions_exception_raises() -> None:
client = ContentClientGrpc()
client._channel = MagicMock()
client._mock = MagicMock()
client._mock.create_questions = AsyncMock(side_effect=RuntimeError("boom"))
with pytest.raises(AIError) as exc_info:
await client.create_questions([], user_id="u-1")
assert exc_info.value.code == ErrorCode.AI_DOWNSTREAM_UNAVAILABLE
# ---------------------------------------------------------------------------
# DataAnaClientGrpc
# ---------------------------------------------------------------------------
async def test_data_ana_grpc_connect_close() -> None:
with patch("grpc.aio.insecure_channel") as mock_fn:
mock_channel = AsyncMock()
mock_channel.close = AsyncMock()
mock_fn.return_value = mock_channel
client = DataAnaClientGrpc()
assert client.is_available() is False
await client.connect()
assert client.is_available() is True
await client.close()
assert client.is_available() is False
async def test_data_ana_grpc_methods_not_connected() -> None:
client = DataAnaClientGrpc()
assert client.is_available() is False
perf = await client.get_class_performance("c-1", "math")
assert perf.class_id == "c-1"
assert perf.average_score > 0
weak = await client.get_student_weakness("s-1", "math")
assert weak.student_id == "s-1"
assert len(weak.weak_points) > 0
trend = await client.get_learning_trend("s-1")
assert trend.student_id == "s-1"
assert len(trend.points) > 0
async def test_data_ana_grpc_methods_connected() -> None:
client = DataAnaClientGrpc()
client._channel = MagicMock()
assert client.is_available() is True
perf = await client.get_class_performance("c-1", "math")
assert perf.class_id == "c-1"
weak = await client.get_student_weakness("s-1", "math")
assert weak.student_id == "s-1"
trend = await client.get_learning_trend("s-1")
assert trend.student_id == "s-1"
# ---------------------------------------------------------------------------
# IamClientGrpc
# ---------------------------------------------------------------------------
async def test_iam_grpc_connect_close() -> None:
with patch("grpc.aio.insecure_channel") as mock_fn:
mock_channel = AsyncMock()
mock_channel.close = AsyncMock()
mock_fn.return_value = mock_channel
client = IamClientGrpc()
assert client.is_available() is False
await client.connect()
assert client.is_available() is True
await client.close()
assert client.is_available() is False
async def test_iam_grpc_get_effective_data_scope() -> None:
client = IamClientGrpc()
assert client.is_available() is False
scope = await client.get_effective_data_scope("u-1")
assert scope.user_id == "u-1"
assert scope.school_id == "school_mock_001"
assert len(scope.class_ids) > 0
client._channel = MagicMock()
assert client.is_available() is True
scope2 = await client.get_effective_data_scope("u-2")
assert scope2.user_id == "u-2"
# ---------------------------------------------------------------------------
# require_permission decorator
# ---------------------------------------------------------------------------
async def test_require_permission_passes() -> None:
guard = PermissionGuard(dev_mode=False)
ctx = UserContext(user_id="u-1", role="teacher")
@require_permission(PERMISSION_AI_CHAT, guard)
async def handler(*, user_context: UserContext) -> str:
return "ok"
result = await handler(user_context=ctx)
assert result == "ok"
async def test_require_permission_forbidden() -> None:
guard = PermissionGuard(dev_mode=False)
ctx = UserContext(user_id="u-1", role="student")
@require_permission(PERMISSION_AI_LESSON_GENERATE, guard)
async def handler(*, user_context: UserContext) -> str:
return "ok"
with pytest.raises(AIError) as exc_info:
await handler(user_context=ctx)
assert exc_info.value.code == ErrorCode.AI_FORBIDDEN
async def test_require_permission_no_context_defaults_unauthenticated() -> None:
guard = PermissionGuard(dev_mode=False)
@require_permission(PERMISSION_AI_CHAT, guard)
async def handler() -> str:
return "ok"
with pytest.raises(AIError) as exc_info:
await handler()
assert exc_info.value.code == ErrorCode.AI_UNAUTHORIZED
async def test_require_permission_finds_ctx_in_args() -> None:
guard = PermissionGuard(dev_mode=False)
ctx = UserContext(user_id="u-1", role="teacher")
@require_permission(PERMISSION_AI_CHAT, guard)
async def handler(user_context: UserContext) -> str:
return "ok"
result = await handler(ctx)
assert result == "ok"
# ---------------------------------------------------------------------------
# WorkflowStateStore — Redis paths
# ---------------------------------------------------------------------------
async def test_workflow_create_with_redis() -> None:
mock_redis = AsyncMock()
mock_redis.setex = AsyncMock()
store = WorkflowStateStore(redis=mock_redis)
state = WorkflowState(user_id="u-1", topic="test")
created = await store.create(state)
mock_redis.setex.assert_called_once()
assert created.workflow_id == state.workflow_id
async def test_workflow_get_with_redis() -> None:
mock_redis = AsyncMock()
state = WorkflowState(user_id="u-1", topic="test")
mock_redis.get = AsyncMock(return_value=json.dumps(state.to_dict()))
store = WorkflowStateStore(redis=mock_redis)
fetched = await store.get(state.workflow_id)
assert fetched.user_id == "u-1"
assert fetched.topic == "test"
async def test_workflow_update_with_redis() -> None:
mock_redis = AsyncMock()
state = WorkflowState(user_id="u-1")
mock_redis.get = AsyncMock(return_value=json.dumps(state.to_dict()))
mock_redis.setex = AsyncMock()
store = WorkflowStateStore(redis=mock_redis)
updated = await store.update(state.workflow_id, status="analyzing")
assert updated.status == "analyzing"
mock_redis.setex.assert_called_once()
async def test_workflow_delete_with_redis() -> None:
mock_redis = AsyncMock()
mock_redis.delete = AsyncMock()
store = WorkflowStateStore(redis=mock_redis)
await store.delete("wf-1")
mock_redis.delete.assert_called_once()
async def test_workflow_create_redis_error_degraded() -> None:
mock_redis = AsyncMock()
mock_redis.setex = AsyncMock(side_effect=RedisError("conn"))
store = WorkflowStateStore(redis=mock_redis)
state = WorkflowState(user_id="u-1")
created = await store.create(state)
assert created.workflow_id == state.workflow_id
async def test_workflow_get_redis_error_degraded() -> None:
mock_redis = AsyncMock()
mock_redis.setex = AsyncMock(side_effect=RedisError("conn"))
mock_redis.get = AsyncMock(side_effect=RedisError("conn"))
store = WorkflowStateStore(redis=mock_redis)
state = WorkflowState(user_id="u-1")
await store.create(state)
fetched = await store.get(state.workflow_id)
assert fetched.user_id == "u-1"
async def test_workflow_delete_redis_error_no_crash() -> None:
mock_redis = AsyncMock()
mock_redis.delete = AsyncMock(side_effect=RedisError("conn"))
store = WorkflowStateStore(redis=mock_redis)
await store.delete("wf-1")
# ---------------------------------------------------------------------------
# grpc_error_mapper
# ---------------------------------------------------------------------------
def test_grpc_error_mapper_ai_error() -> None:
exc = AIError(ErrorCode.AI_UNAUTHORIZED, "not authenticated")
code, msg, status = grpc_error_mapper(exc)
assert code == "AI_UNAUTHORIZED"
assert msg == "not authenticated"
assert status == 8
def test_grpc_error_mapper_unknown_exception() -> None:
exc = RuntimeError("unexpected")
code, msg, status = grpc_error_mapper(exc)
assert code == "AI_INTERNAL_ERROR"
assert msg == "Internal server error"
assert status == 13
def test_grpc_error_mapper_various_codes() -> None:
assert grpc_error_mapper(AIError(ErrorCode.AI_FORBIDDEN, "denied"))[2] == 7
assert grpc_error_mapper(AIRateLimitedError("user", 10))[2] == 9
assert grpc_error_mapper(AIError(ErrorCode.AI_WORKFLOW_NOT_FOUND, "nf"))[2] == 5
assert grpc_error_mapper(AIError(ErrorCode.AI_LLM_UNAVAILABLE, "down"))[2] == 14
assert grpc_error_mapper(AIError(ErrorCode.AI_INVALID_MODEL, "bad"))[2] == 3
assert grpc_error_mapper(AIError(ErrorCode.AI_WORKFLOW_STATE_INVALID, "bad"))[2] == 10
assert grpc_error_mapper(AIError(ErrorCode.AI_INTERNAL_ERROR, "err"))[2] == 13
# ---------------------------------------------------------------------------
# Config — Settings properties
# ---------------------------------------------------------------------------
def test_settings_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
for key in ("SERVICE_NAME", "HTTP_PORT", "GRPC_PORT", "DEV_MODE"):
monkeypatch.delenv(key, raising=False)
s = Settings(_env_file=None)
assert s.service_name == "ai"
assert s.http_port == 3008
assert s.grpc_port == 50058
assert s.dev_mode is False
def test_settings_dev_mode() -> None:
s = Settings(_env_file=None, dev_mode=True)
assert s.is_dev is True
def test_settings_providers_status() -> None:
s = Settings(_env_file=None, openai_api_key="sk-test")
status = s.providers_status
assert set(status.keys()) == {"openai", "anthropic", "baichuan", "local_ollama"}
assert status["openai"] is True
def test_settings_llm_available() -> None:
s = Settings(_env_file=None, openai_api_key="sk-test")
assert s.llm_available is True
def test_settings_provider_priority_list() -> None:
s = Settings(_env_file=None)
lst = s.provider_priority_list
assert isinstance(lst, list)
assert "openai" in lst
assert "anthropic" in lst