743 lines
25 KiB
Python
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
|