""" Contract validation tests for AI Service. [AC-AISVC-02] Verify response fields match openapi.provider.yaml contract. OpenAPI ChatResponse schema: - reply: string (required) - confidence: number (double, required) - shouldTransfer: boolean (required) - transferReason: string (optional) - metadata: object (optional) """ import json import pytest from pydantic import ValidationError from app.models import ( ChatResponse, ChatRequest, ChatMessage, Role, ChannelType, ErrorResponse, SSEFinalEvent, SSEErrorEvent, ) class TestChatResponseContract: """ [AC-AISVC-02] Test ChatResponse matches OpenAPI contract. """ def test_required_fields_present(self): """ [AC-AISVC-02] ChatResponse must have reply, confidence, shouldTransfer. """ response = ChatResponse( reply="Test reply", confidence=0.85, should_transfer=False, ) assert response.reply == "Test reply" assert response.confidence == 0.85 assert response.should_transfer is False def test_json_serialization_uses_camel_case(self): """ [AC-AISVC-02] JSON output must use camelCase per OpenAPI contract. Field names: shouldTransfer, transferReason (not snake_case) """ response = ChatResponse( reply="Test reply", confidence=0.85, should_transfer=True, transfer_reason="Low confidence", metadata={"key": "value"}, ) json_str = response.model_dump_json(by_alias=True) data = json.loads(json_str) assert "shouldTransfer" in data assert "should_transfer" not in data assert "transferReason" in data assert "transfer_reason" not in data def test_json_output_matches_contract_structure(self): """ [AC-AISVC-02] JSON output structure must match OpenAPI schema exactly. Optional fields with None values are included as null in JSON. """ response = ChatResponse( reply="AI response content", confidence=0.92, should_transfer=False, transfer_reason=None, metadata={"session_id": "test-123"}, ) data = json.loads(response.model_dump_json(by_alias=True)) assert "reply" in data assert "confidence" in data assert "shouldTransfer" in data assert "transferReason" in data assert "metadata" in data assert data["reply"] == "AI response content" assert data["confidence"] == 0.92 assert data["shouldTransfer"] is False assert data["transferReason"] is None assert data["metadata"]["session_id"] == "test-123" def test_optional_fields_can_be_omitted(self): """ [AC-AISVC-02] transferReason and metadata are optional. """ response = ChatResponse( reply="Reply without optional fields", confidence=0.5, should_transfer=True, ) json_str = response.model_dump_json(by_alias=True) data = json.loads(json_str) assert data["reply"] == "Reply without optional fields" assert data["confidence"] == 0.5 assert data["shouldTransfer"] is True assert data.get("transferReason") is None assert data.get("metadata") is None def test_confidence_must_be_between_0_and_1(self): """ [AC-AISVC-02] confidence must be in range [0.0, 1.0]. """ valid_response = ChatResponse( reply="Valid", confidence=0.0, should_transfer=False, ) assert valid_response.confidence == 0.0 valid_response = ChatResponse( reply="Valid", confidence=1.0, should_transfer=False, ) assert valid_response.confidence == 1.0 def test_confidence_rejects_negative(self): """ [AC-AISVC-02] confidence must reject negative values. """ with pytest.raises(ValidationError): ChatResponse( reply="Invalid", confidence=-0.1, should_transfer=False, ) def test_confidence_rejects_above_1(self): """ [AC-AISVC-02] confidence must reject values > 1.0. """ with pytest.raises(ValidationError): ChatResponse( reply="Invalid", confidence=1.5, should_transfer=False, ) def test_reply_is_required(self): """ [AC-AISVC-02] reply field is required. """ with pytest.raises(ValidationError): ChatResponse( confidence=0.5, should_transfer=False, ) def test_confidence_is_required(self): """ [AC-AISVC-02] confidence field is required. """ with pytest.raises(ValidationError): ChatResponse( reply="Test", should_transfer=False, ) def test_should_transfer_is_required(self): """ [AC-AISVC-02] shouldTransfer field is required. """ with pytest.raises(ValidationError): ChatResponse( reply="Test", confidence=0.5, ) def test_transfer_reason_accepts_string(self): """ [AC-AISVC-02] transferReason accepts string value. """ response = ChatResponse( reply="Test", confidence=0.3, should_transfer=True, transfer_reason="检索结果不足,建议转人工", ) data = json.loads(response.model_dump_json(by_alias=True)) assert data["transferReason"] == "检索结果不足,建议转人工" def test_metadata_accepts_any_object(self): """ [AC-AISVC-02] metadata accepts any object with additionalProperties. """ response = ChatResponse( reply="Test", confidence=0.8, should_transfer=False, metadata={ "session_id": "session-123", "channel_type": "wechat", "diagnostics": { "retrieval_hits": 5, "llm_model": "gpt-4o-mini", }, }, ) data = json.loads(response.model_dump_json(by_alias=True)) assert data["metadata"]["session_id"] == "session-123" assert data["metadata"]["diagnostics"]["retrieval_hits"] == 5 class TestChatRequestContract: """ [AC-AISVC-02] Test ChatRequest matches OpenAPI contract. """ def test_required_fields(self): """ [AC-AISVC-02] ChatRequest required fields: sessionId, currentMessage, channelType. """ request = ChatRequest( session_id="session-123", current_message="Hello", channel_type=ChannelType.WECHAT, ) assert request.session_id == "session-123" assert request.current_message == "Hello" assert request.channel_type == ChannelType.WECHAT def test_json_input_uses_camel_case(self): """ [AC-AISVC-02] JSON input should accept camelCase field names. """ json_data = { "sessionId": "session-456", "currentMessage": "What is the price?", "channelType": "wechat", } request = ChatRequest.model_validate(json_data) assert request.session_id == "session-456" assert request.current_message == "What is the price?" def test_optional_history_field(self): """ [AC-AISVC-02] history is optional. """ request = ChatRequest( session_id="session-789", current_message="Follow-up question", channel_type=ChannelType.DOUYIN, history=[ ChatMessage(role=Role.USER, content="Previous question"), ChatMessage(role=Role.ASSISTANT, content="Previous answer"), ], ) assert len(request.history) == 2 assert request.history[0].role == Role.USER def test_channel_type_enum_values(self): """ [AC-AISVC-02] channelType must be one of: wechat, douyin, jd. """ valid_types = ["wechat", "douyin", "jd"] for channel in valid_types: request = ChatRequest( session_id="test", current_message="Test", channel_type=channel, ) assert request.channel_type.value == channel class TestErrorResponseContract: """ [AC-AISVC-02] Test ErrorResponse matches OpenAPI contract. """ def test_required_fields(self): """ [AC-AISVC-02] ErrorResponse required fields: code, message. """ response = ErrorResponse( code="INVALID_REQUEST", message="Missing required field", ) assert response.code == "INVALID_REQUEST" assert response.message == "Missing required field" def test_optional_details(self): """ [AC-AISVC-02] details is optional array. """ response = ErrorResponse( code="VALIDATION_ERROR", message="Multiple validation errors", details=[ {"field": "sessionId", "error": "required"}, {"field": "channelType", "error": "invalid value"}, ], ) assert len(response.details) == 2 class TestSSEFinalEventContract: """ [AC-AISVC-02] Test SSE final event matches OpenAPI ChatResponse structure. """ def test_sse_final_event_structure(self): """ [AC-AISVC-02] SSE final event must have same structure as ChatResponse. """ event = SSEFinalEvent( reply="Complete AI response", confidence=0.88, should_transfer=False, transfer_reason=None, metadata={"tokens": 150}, ) data = json.loads(event.model_dump_json(by_alias=True)) assert "reply" in data assert "confidence" in data assert "shouldTransfer" in data assert data["shouldTransfer"] is False def test_sse_final_event_matches_chat_response(self): """ [AC-AISVC-02] SSEFinalEvent fields must match ChatResponse exactly. """ chat_response = ChatResponse( reply="Test reply", confidence=0.75, should_transfer=True, transfer_reason="Low confidence", metadata={"test": "value"}, ) sse_event = SSEFinalEvent( reply="Test reply", confidence=0.75, should_transfer=True, transfer_reason="Low confidence", metadata={"test": "value"}, ) chat_data = json.loads(chat_response.model_dump_json(by_alias=True)) sse_data = json.loads(sse_event.model_dump_json(by_alias=True)) assert chat_data == sse_data class TestSSEErrorEventContract: """ [AC-AISVC-02] Test SSE error event matches OpenAPI ErrorResponse structure. """ def test_sse_error_event_structure(self): """ [AC-AISVC-02] SSE error event must have same structure as ErrorResponse. """ event = SSEErrorEvent( code="GENERATION_ERROR", message="LLM service unavailable", details=[{"reason": "timeout"}], ) data = json.loads(event.model_dump_json()) assert data["code"] == "GENERATION_ERROR" assert data["message"] == "LLM service unavailable" assert len(data["details"]) == 1 class TestEndToEndContractValidation: """ [AC-AISVC-02] End-to-end contract validation with OrchestratorService. """ @pytest.mark.asyncio async def test_orchestrator_response_matches_contract(self): """ [AC-AISVC-02] OrchestratorService.generate() returns valid ChatResponse. """ from app.services.orchestrator import OrchestratorService, OrchestratorConfig orchestrator = OrchestratorService( config=OrchestratorConfig(enable_rag=False), ) request = ChatRequest( session_id="contract-test-session", current_message="Test message", channel_type=ChannelType.WECHAT, ) response = await orchestrator.generate( tenant_id="tenant-1", request=request, ) assert isinstance(response, ChatResponse) assert isinstance(response.reply, str) assert isinstance(response.confidence, float) assert 0.0 <= response.confidence <= 1.0 assert isinstance(response.should_transfer, bool) @pytest.mark.asyncio async def test_orchestrator_response_json_serializable(self): """ [AC-AISVC-02] OrchestratorService response must be JSON serializable. """ from app.services.orchestrator import OrchestratorService, OrchestratorConfig orchestrator = OrchestratorService( config=OrchestratorConfig(enable_rag=False), ) request = ChatRequest( session_id="json-test-session", current_message="JSON serialization test", channel_type=ChannelType.JD, ) response = await orchestrator.generate( tenant_id="tenant-1", request=request, ) json_str = response.model_dump_json(by_alias=True) data = json.loads(json_str) assert "reply" in data assert "confidence" in data assert "shouldTransfer" in data assert "should_transfer" not in data