Skip to content

Commit 914e1c9

Browse files
test:add comprehensive test coverage for AI processor and direct invocation
1 parent ad03f08 commit 914e1c9

File tree

4 files changed

+483
-40
lines changed

4 files changed

+483
-40
lines changed
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""shared ai processor - validates query reformulation and bedrock integration"""
2+
3+
import pytest
4+
from unittest.mock import patch
5+
from app.services.ai_processor import process_ai_query
6+
7+
8+
class TestAIProcessor:
9+
10+
@patch("app.services.ai_processor.query_bedrock")
11+
@patch("app.services.ai_processor.reformulate_query")
12+
def test_process_ai_query_without_session(self, mock_reformulate, mock_bedrock):
13+
"""new conversation: no session context passed to bedrock"""
14+
mock_reformulate.return_value = "reformulated: How to authenticate EPS API?"
15+
mock_bedrock.return_value = {
16+
"output": {"text": "To authenticate with EPS API, you need..."},
17+
"sessionId": "new-session-abc123",
18+
"citations": [{"title": "EPS Authentication Guide", "uri": "https://example.com/auth"}],
19+
}
20+
21+
result = process_ai_query("How to authenticate EPS API?")
22+
23+
assert result["text"] == "To authenticate with EPS API, you need..."
24+
assert result["session_id"] == "new-session-abc123"
25+
assert len(result["citations"]) == 1
26+
assert result["citations"][0]["title"] == "EPS Authentication Guide"
27+
assert "kb_response" in result
28+
29+
mock_reformulate.assert_called_once_with("How to authenticate EPS API?")
30+
mock_bedrock.assert_called_once_with("reformulated: How to authenticate EPS API?", None)
31+
32+
@patch("app.services.ai_processor.query_bedrock")
33+
@patch("app.services.ai_processor.reformulate_query")
34+
def test_process_ai_query_with_session(self, mock_reformulate, mock_bedrock):
35+
"""conversation continuity: existing session maintained across queries"""
36+
mock_reformulate.return_value = "reformulated: What about rate limits?"
37+
mock_bedrock.return_value = {
38+
"output": {"text": "EPS API has rate limits of..."},
39+
"sessionId": "existing-session-456",
40+
"citations": [],
41+
}
42+
43+
result = process_ai_query("What about rate limits?", session_id="existing-session-456")
44+
45+
assert result["text"] == "EPS API has rate limits of..."
46+
assert result["session_id"] == "existing-session-456"
47+
assert result["citations"] == []
48+
assert "kb_response" in result
49+
50+
mock_reformulate.assert_called_once_with("What about rate limits?")
51+
mock_bedrock.assert_called_once_with("reformulated: What about rate limits?", "existing-session-456")
52+
53+
@patch("app.services.ai_processor.query_bedrock")
54+
@patch("app.services.ai_processor.reformulate_query")
55+
def test_process_ai_query_reformulate_error(self, mock_reformulate, mock_bedrock):
56+
"""graceful degradation: reformulation failure bubbles up"""
57+
mock_reformulate.side_effect = Exception("Query reformulation failed")
58+
59+
with pytest.raises(Exception) as exc_info:
60+
process_ai_query("How to authenticate EPS API?")
61+
62+
assert "Query reformulation failed" in str(exc_info.value)
63+
mock_bedrock.assert_not_called()
64+
65+
@patch("app.services.ai_processor.query_bedrock")
66+
@patch("app.services.ai_processor.reformulate_query")
67+
def test_process_ai_query_bedrock_error(self, mock_reformulate, mock_bedrock):
68+
"""bedrock service failure: error propagated to caller"""
69+
mock_reformulate.return_value = "reformulated query"
70+
mock_bedrock.side_effect = Exception("Bedrock service error")
71+
72+
with pytest.raises(Exception) as exc_info:
73+
process_ai_query("How to authenticate EPS API?")
74+
75+
assert "Bedrock service error" in str(exc_info.value)
76+
mock_reformulate.assert_called_once()
77+
78+
@patch("app.services.ai_processor.query_bedrock")
79+
@patch("app.services.ai_processor.reformulate_query")
80+
def test_process_ai_query_missing_citations(self, mock_reformulate, mock_bedrock):
81+
"""bedrock response incomplete: citations default to empty list"""
82+
mock_reformulate.return_value = "reformulated query"
83+
mock_bedrock.return_value = {
84+
"output": {"text": "Response without citations"},
85+
"sessionId": "session-123",
86+
# No citations key
87+
}
88+
89+
result = process_ai_query("test query")
90+
91+
assert result["text"] == "Response without citations"
92+
assert result["session_id"] == "session-123"
93+
assert result["citations"] == [] # safe default when bedrock omits citations
94+
95+
@patch("app.services.ai_processor.query_bedrock")
96+
@patch("app.services.ai_processor.reformulate_query")
97+
def test_process_ai_query_missing_session_id(self, mock_reformulate, mock_bedrock):
98+
"""bedrock response incomplete: session_id properly handles None"""
99+
mock_reformulate.return_value = "reformulated query"
100+
mock_bedrock.return_value = {
101+
"output": {"text": "Response without session"},
102+
"citations": [],
103+
# No sessionId key
104+
}
105+
106+
result = process_ai_query("test query")
107+
108+
assert result["text"] == "Response without session"
109+
assert result["session_id"] is None # explicit None when bedrock omits sessionId
110+
assert result["citations"] == []
111+
112+
@patch("app.services.ai_processor.query_bedrock")
113+
@patch("app.services.ai_processor.reformulate_query")
114+
def test_process_ai_query_empty_query(self, mock_reformulate, mock_bedrock):
115+
"""edge case: empty query still processed through full pipeline"""
116+
mock_reformulate.return_value = ""
117+
mock_bedrock.return_value = {
118+
"output": {"text": "Please provide a question"},
119+
"sessionId": "session-empty",
120+
"citations": [],
121+
}
122+
123+
result = process_ai_query("")
124+
125+
assert result["text"] == "Please provide a question"
126+
mock_reformulate.assert_called_once_with("")
127+
mock_bedrock.assert_called_once_with("", None)
128+
129+
@patch("app.services.ai_processor.query_bedrock")
130+
@patch("app.services.ai_processor.reformulate_query")
131+
def test_process_ai_query_includes_raw_response(self, mock_reformulate, mock_bedrock):
132+
"""slack needs raw bedrock data: kb_response preserved for session handling"""
133+
mock_reformulate.return_value = "reformulated query"
134+
raw_response = {
135+
"output": {"text": "Test response"},
136+
"sessionId": "test-123",
137+
"citations": [{"title": "Test", "uri": "test.com"}],
138+
"metadata": {"some": "extra_data"},
139+
}
140+
mock_bedrock.return_value = raw_response
141+
142+
result = process_ai_query("test query")
143+
144+
assert result["kb_response"] == raw_response
145+
assert result["kb_response"]["metadata"]["some"] == "extra_data"
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""direct lambda invocation - validates bypassing slack infrastructure entirely"""
2+
3+
from unittest.mock import Mock, patch
4+
from app.handler import handle_direct_invocation
5+
6+
7+
class TestDirectInvocation:
8+
9+
@patch("app.services.ai_processor.process_ai_query")
10+
def test_successful_direct_invocation_without_session(self, mock_process_ai_query):
11+
"""new conversation: no session context from previous queries"""
12+
mock_process_ai_query.return_value = {
13+
"text": "AI response about EPS API authentication",
14+
"session_id": "new-session-123",
15+
"citations": [{"title": "EPS API Guide", "uri": "https://example.com"}],
16+
"kb_response": {"sessionId": "new-session-123"},
17+
}
18+
19+
event = {"invocation_type": "direct", "query": "How do I authenticate with EPS API?"}
20+
21+
result = handle_direct_invocation(event, Mock())
22+
23+
assert result["statusCode"] == 200
24+
assert result["response"]["text"] == "AI response about EPS API authentication"
25+
assert result["response"]["session_id"] == "new-session-123"
26+
assert len(result["response"]["citations"]) == 1
27+
assert "timestamp" in result["response"]
28+
29+
mock_process_ai_query.assert_called_once_with("How do I authenticate with EPS API?", None)
30+
31+
@patch("app.services.ai_processor.process_ai_query")
32+
def test_successful_direct_invocation_with_session(self, mock_process_ai_query):
33+
"""conversation continuity: session maintained across direct calls"""
34+
mock_process_ai_query.return_value = {
35+
"text": "Follow-up response",
36+
"session_id": "existing-session-456",
37+
"citations": [],
38+
"kb_response": {"sessionId": "existing-session-456"},
39+
}
40+
41+
event = {"invocation_type": "direct", "query": "What about rate limits?", "session_id": "existing-session-456"}
42+
43+
result = handle_direct_invocation(event, Mock())
44+
45+
assert result["statusCode"] == 200
46+
assert result["response"]["text"] == "Follow-up response"
47+
assert result["response"]["session_id"] == "existing-session-456"
48+
assert result["response"]["citations"] == []
49+
assert "timestamp" in result["response"]
50+
51+
mock_process_ai_query.assert_called_once_with("What about rate limits?", "existing-session-456")
52+
53+
def test_direct_invocation_missing_query(self):
54+
"""input validation: query field required for processing"""
55+
event = {"invocation_type": "direct"}
56+
57+
result = handle_direct_invocation(event, Mock())
58+
59+
assert result["statusCode"] == 400
60+
assert "Missing required field: query" in result["response"]["error"]
61+
assert "timestamp" in result["response"]
62+
63+
def test_direct_invocation_empty_query(self):
64+
"""edge case: empty string treated same as missing query"""
65+
event = {"invocation_type": "direct", "query": ""}
66+
67+
result = handle_direct_invocation(event, Mock())
68+
69+
assert result["statusCode"] == 400
70+
assert "Missing required field: query" in result["response"]["error"]
71+
assert "timestamp" in result["response"]
72+
73+
@patch("app.services.ai_processor.process_ai_query")
74+
def test_direct_invocation_processing_error(self, mock_process_ai_query):
75+
"""ai service failure: graceful error response to caller"""
76+
mock_process_ai_query.side_effect = Exception("Bedrock service unavailable")
77+
78+
event = {"invocation_type": "direct", "query": "How do I authenticate with EPS API?"}
79+
80+
result = handle_direct_invocation(event, Mock())
81+
82+
assert result["statusCode"] == 500
83+
assert result["response"]["error"] == "Internal server error"
84+
assert "timestamp" in result["response"]
85+
86+
@patch("app.services.ai_processor.process_ai_query")
87+
def test_direct_invocation_with_none_query(self, mock_process_ai_query):
88+
"""edge case: None query handled same as empty string"""
89+
event = {"invocation_type": "direct", "query": None}
90+
91+
result = handle_direct_invocation(event, Mock())
92+
93+
assert result["statusCode"] == 400
94+
assert "Missing required field: query" in result["response"]["error"]
95+
96+
@patch("app.services.ai_processor.process_ai_query")
97+
def test_direct_invocation_whitespace_query(self, mock_process_ai_query):
98+
"""input sanitization: whitespace-only queries rejected"""
99+
event = {"invocation_type": "direct", "query": " \n\t "}
100+
101+
result = handle_direct_invocation(event, Mock())
102+
103+
assert result["statusCode"] == 400
104+
assert "Missing required field: query" in result["response"]["error"]
105+
106+
@patch("app.services.ai_processor.process_ai_query")
107+
def test_direct_invocation_response_structure(self, mock_process_ai_query):
108+
"""api contract: response structure matches expected format"""
109+
mock_process_ai_query.return_value = {
110+
"text": "Test response",
111+
"session_id": "test-session",
112+
"citations": [
113+
{"title": "Doc 1", "uri": "https://example.com/1"},
114+
{"title": "Doc 2", "uri": "https://example.com/2"},
115+
],
116+
"kb_response": {"sessionId": "test-session"},
117+
}
118+
119+
event = {"invocation_type": "direct", "query": "Test query"}
120+
121+
result = handle_direct_invocation(event, Mock())
122+
123+
# api contract validation: all required fields present
124+
assert "statusCode" in result
125+
assert "response" in result
126+
assert "text" in result["response"]
127+
assert "session_id" in result["response"]
128+
assert "citations" in result["response"]
129+
assert "timestamp" in result["response"]
130+
131+
# citation passthrough: bedrock data structure preserved
132+
assert len(result["response"]["citations"]) == 2
133+
assert result["response"]["citations"][0]["title"] == "Doc 1"
134+
assert result["response"]["citations"][1]["uri"] == "https://example.com/2"
135+
136+
@patch("app.services.ai_processor.process_ai_query")
137+
def test_direct_invocation_timestamp_format(self, mock_process_ai_query):
138+
"""timestamp format: iso8601 with Z suffix for consistency"""
139+
mock_process_ai_query.return_value = {
140+
"text": "Test response",
141+
"session_id": None,
142+
"citations": [],
143+
"kb_response": {},
144+
}
145+
146+
event = {"invocation_type": "direct", "query": "Test query"}
147+
148+
result = handle_direct_invocation(event, Mock())
149+
150+
timestamp = result["response"]["timestamp"]
151+
# iso8601 validation: parseable datetime with utc marker
152+
assert timestamp.endswith("Z")
153+
assert "T" in timestamp
154+
# format verification: datetime parsing confirms structure
155+
from datetime import datetime
156+
157+
datetime.fromisoformat(timestamp.rstrip("Z"))

0 commit comments

Comments
 (0)