848 lines
29 KiB
Python
848 lines
29 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:
|
||
"""未连接时调用方法应抛 AIError(不降级到 mock)."""
|
||
client = ContentClientGrpc()
|
||
assert client.is_available() is False
|
||
with pytest.raises(AIError, match="not connected"):
|
||
await client.get_prerequisites("kp-1")
|
||
with pytest.raises(AIError, match="not connected"):
|
||
await client.get_learning_path("s-1", "math")
|
||
with pytest.raises(AIError, match="not connected"):
|
||
await client.create_questions([], user_id="u-1")
|
||
|
||
|
||
async def test_content_grpc_methods_connected() -> None:
|
||
"""连接后通过 gRPC stub 发起真实调用(使用 mock stub 验证)."""
|
||
from src.ai.proto_gen import content_pb2
|
||
|
||
client = ContentClientGrpc()
|
||
client._channel = MagicMock()
|
||
# 构造 mock stub 返回 proto 响应
|
||
mock_kg_stub = MagicMock()
|
||
mock_kg_stub.GetPrerequisites = AsyncMock(
|
||
return_value=content_pb2.KnowledgePointsResponse(
|
||
points=[
|
||
content_pb2.KnowledgePoint(id="kp_pre", title="前置知识点"),
|
||
],
|
||
),
|
||
)
|
||
mock_kg_stub.GetLearningPath = AsyncMock(
|
||
return_value=content_pb2.LearningPath(
|
||
points=[
|
||
content_pb2.KnowledgePoint(id="kp_1", title="知识点1"),
|
||
content_pb2.KnowledgePoint(id="kp_2", title="知识点2"),
|
||
],
|
||
),
|
||
)
|
||
mock_q_stub = MagicMock()
|
||
mock_q_stub.BatchCreateQuestions = AsyncMock(
|
||
return_value=content_pb2.BatchCreateQuestionsResponse(ids=["q_new_1"]),
|
||
)
|
||
client._kg_stub = mock_kg_stub
|
||
client._q_stub = mock_q_stub
|
||
assert client.is_available() is True
|
||
|
||
pre = await client.get_prerequisites("kp-1")
|
||
assert len(pre) == 1
|
||
assert pre[0].id == "kp_pre"
|
||
|
||
path = await client.get_learning_path("s-1", "math")
|
||
assert len(path) == 2
|
||
|
||
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
|
||
assert result[0].id == "q_new_1"
|
||
|
||
|
||
async def test_content_grpc_create_questions_exception_raises() -> None:
|
||
"""gRPC 调用失败时包装为 AIError."""
|
||
import grpc
|
||
|
||
client = ContentClientGrpc()
|
||
client._channel = MagicMock()
|
||
mock_q_stub = MagicMock()
|
||
mock_q_stub.BatchCreateQuestions = AsyncMock(
|
||
side_effect=grpc.aio.AioRpcError(
|
||
code=grpc.StatusCode.UNAVAILABLE,
|
||
initial_metadata=[],
|
||
trailing_metadata=[],
|
||
details="service unavailable",
|
||
),
|
||
)
|
||
client._q_stub = mock_q_stub
|
||
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:
|
||
"""未连接时调用方法应抛 AIError."""
|
||
client = DataAnaClientGrpc()
|
||
assert client.is_available() is False
|
||
with pytest.raises(AIError, match="not connected"):
|
||
await client.get_class_performance("c-1", "math")
|
||
with pytest.raises(AIError, match="not connected"):
|
||
await client.get_student_weakness("s-1", "math")
|
||
with pytest.raises(AIError, match="not connected"):
|
||
await client.get_learning_trend("s-1")
|
||
|
||
|
||
async def test_data_ana_grpc_methods_connected() -> None:
|
||
"""连接后通过 gRPC stub 真实调用."""
|
||
from src.ai.proto_gen import analytics_pb2
|
||
|
||
client = DataAnaClientGrpc()
|
||
client._channel = MagicMock()
|
||
mock_stub = MagicMock()
|
||
mock_stub.GetClassPerformance = AsyncMock(
|
||
return_value=analytics_pb2.ClassPerformance(
|
||
class_id="c-1",
|
||
average_score=82.5,
|
||
pass_rate=0.9,
|
||
scores=[
|
||
analytics_pb2.StudentScore(student_id="s-1", score=85.0, grade="A"),
|
||
],
|
||
),
|
||
)
|
||
mock_stub.GetStudentWeakness = AsyncMock(
|
||
return_value=analytics_pb2.StudentWeakness(
|
||
student_id="s-1",
|
||
weak_points=[
|
||
analytics_pb2.WeakPoint(
|
||
knowledge_point_id="kp-1",
|
||
title="函数",
|
||
mastery=0.4,
|
||
),
|
||
],
|
||
),
|
||
)
|
||
mock_stub.GetLearningTrend = AsyncMock(
|
||
return_value=analytics_pb2.LearningTrend(
|
||
student_id="s-1",
|
||
points=[
|
||
analytics_pb2.TrendPoint(date=20260101, score=70.0),
|
||
analytics_pb2.TrendPoint(date=20260201, score=75.0),
|
||
],
|
||
),
|
||
)
|
||
client._stub = mock_stub
|
||
assert client.is_available() is True
|
||
|
||
perf = await client.get_class_performance("c-1", "math")
|
||
assert perf.class_id == "c-1"
|
||
assert perf.average_score == 82.5
|
||
assert len(perf.scores) == 1
|
||
|
||
weak = await client.get_student_weakness("s-1", "math")
|
||
assert weak.student_id == "s-1"
|
||
assert len(weak.weak_points) == 1
|
||
assert weak.weak_points[0].knowledge_point_id == "kp-1"
|
||
|
||
trend = await client.get_learning_trend("s-1")
|
||
assert trend.student_id == "s-1"
|
||
assert len(trend.points) == 2
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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:
|
||
"""未连接时抛 AIError;连接后通过 gRPC stub 真实调用."""
|
||
from src.ai.proto_gen import iam_pb2
|
||
|
||
client = IamClientGrpc()
|
||
assert client.is_available() is False
|
||
with pytest.raises(AIError, match="not connected"):
|
||
await client.get_effective_data_scope("u-1")
|
||
|
||
# 连接后通过 mock stub 调用
|
||
client._channel = MagicMock()
|
||
mock_stub = MagicMock()
|
||
mock_stub.GetEffectiveDataScope = AsyncMock(
|
||
return_value=iam_pb2.EffectiveDataScope(
|
||
user_id="u-1",
|
||
level="CLASS",
|
||
scope_ids=["class_001", "class_002"],
|
||
school_id="school_001",
|
||
),
|
||
)
|
||
client._stub = mock_stub
|
||
assert client.is_available() is True
|
||
|
||
scope = await client.get_effective_data_scope("u-1")
|
||
assert scope.user_id == "u-1"
|
||
assert scope.school_id == "school_001"
|
||
assert scope.class_ids == ["class_001", "class_002"]
|
||
assert scope.is_admin is False
|
||
|
||
# ALL level 测试
|
||
mock_stub.GetEffectiveDataScope = AsyncMock(
|
||
return_value=iam_pb2.EffectiveDataScope(
|
||
user_id="u-admin",
|
||
level="ALL",
|
||
scope_ids=[],
|
||
school_id="",
|
||
),
|
||
)
|
||
scope2 = await client.get_effective_data_scope("u-admin")
|
||
assert scope2.is_admin is True
|
||
assert scope2.role == "admin"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|