Skip to content

Commit 156346b

Browse files
committed
Add regression and integration tests for therapy and safety services
- Introduced regression tests for therapy service covering remission classification, technique selection weights, crisis keyword detection, homework assignment phases, technique duration filters, session state machine behavior, attribute renaming, SFBT techniques, and trend direction reporting. - Implemented end-to-end tests for audit chain integrity, ensuring HMAC signing and tamper detection. - Added tests for PHI at-rest encryption, verifying lifecycle hooks for encrypting and decrypting sensitive data. - Developed comprehensive tests for the SafetyService, validating the crisis pathway, event emissions, and escalation processes. - Created unit tests for feature flags, ensuring SSL enforcement is enabled by default in production and staging environments.
1 parent 894c3c7 commit 156346b

10 files changed

Lines changed: 938 additions & 40 deletions

File tree

services/safety_service/tests/test_escalation.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,25 @@
33
Tests escalation workflows, clinician assignment, and notifications.
44
"""
55
from __future__ import annotations
6+
67
from dataclasses import dataclass
78
from unittest.mock import AsyncMock
8-
import pytest
99
from uuid import UUID, uuid4
10+
11+
import pytest
12+
1013
from services.safety_service.src.domain.escalation import (
11-
EscalationManager, EscalationSettings, EscalationResult,
12-
EscalationPriority, EscalationStatus, NotificationType,
13-
EscalationRecord, NotificationService, ClinicianAssigner,
14-
CrisisResourceManager, EscalationWorkflow,
14+
ClinicianAssigner,
15+
CrisisResourceManager,
16+
EscalationManager,
17+
EscalationPriority,
18+
EscalationRecord,
19+
EscalationResult,
20+
EscalationSettings,
21+
EscalationStatus,
22+
EscalationWorkflow,
23+
NotificationService,
24+
NotificationType,
1525
)
1626

1727

@@ -207,6 +217,48 @@ async def test_medium_workflow(self, workflow: EscalationWorkflow) -> None:
207217
assert "MEDIUM workflow initiated" in result.actions_taken
208218
assert "Enhanced monitoring enabled" in result.actions_taken
209219

220+
@pytest.mark.asyncio
221+
async def test_medium_workflow_sends_real_supervisor_email(
222+
self, workflow: EscalationWorkflow
223+
) -> None:
224+
"""H-03 regression: MEDIUM workflow must actually send a supervisor
225+
email when email notifications are enabled. Previously it logged
226+
``"Event logged for supervisor review"`` as an action but never
227+
called the notification service — a false audit claim.
228+
"""
229+
# Replace the notification service with a capturing spy so we can
230+
# assert the call actually happens. The spy records every
231+
# send_notification invocation.
232+
send_calls: list[dict[str, object]] = []
233+
234+
async def _spy_send(
235+
clinician_id, escalation, channel, retry_count=0,
236+
):
237+
send_calls.append(
238+
{"clinician_id": clinician_id, "channel": channel}
239+
)
240+
return True
241+
242+
workflow._notifications.send_notification = _spy_send # type: ignore[assignment,method-assign]
243+
244+
escalation = EscalationRecord(
245+
user_id=uuid4(),
246+
crisis_level="ELEVATED",
247+
priority=EscalationPriority.MEDIUM,
248+
)
249+
result = await workflow.execute_medium_workflow(escalation)
250+
251+
# If the fix is in place, at least one send_notification call was made
252+
assert len(send_calls) >= 1, (
253+
"H-03 regression: MEDIUM escalation workflow must call "
254+
"notification_service.send_notification() when email is enabled. "
255+
"Previously the workflow only logged a misleading action string."
256+
)
257+
# The recorded action should not claim more than actually happened
258+
assert "Event logged for supervisor review" not in result.actions_taken, (
259+
"H-03 regression: misleading action string removed"
260+
)
261+
210262
@pytest.mark.asyncio
211263
async def test_low_workflow(self, workflow: EscalationWorkflow) -> None:
212264
"""Test LOW priority workflow execution."""

services/safety_service/tests/test_keyword_detector.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
"""
22
Tests for keyword_detector.py - Fast crisis keyword detection with trie-based matching.
33
"""
4-
import pytest
54
from decimal import Decimal
65
from uuid import uuid4
6+
7+
import pytest
8+
79
from services.safety_service.src.ml.keyword_detector import (
10+
KeywordCategory,
811
KeywordDetector,
912
KeywordDetectorConfig,
10-
KeywordMatch,
1113
KeywordSeverity,
12-
KeywordCategory,
1314
)
1415

1516

services/safety_service/tests/test_llm_assessor.py

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
"""
22
Tests for llm_assessor.py - LLM-based deep risk assessment.
33
"""
4-
import pytest
54
from decimal import Decimal
65
from uuid import uuid4
6+
7+
import pytest
8+
79
from services.safety_service.src.ml.llm_assessor import (
810
LLMAssessor,
911
LLMAssessorConfig,
12+
ProtectiveFactor,
1013
RiskAssessment,
11-
RiskLevel,
1214
RiskDimension,
1315
RiskFactor,
14-
ProtectiveFactor,
16+
RiskLevel,
1517
)
1618

1719

@@ -174,6 +176,79 @@ def test_generate_cache_key(self, assessor: LLMAssessor) -> None:
174176
assert key1 == key2 # Same input = same key
175177
assert key1 != key3 # Different input = different key
176178

179+
def test_cache_key_includes_user_id(self, assessor: LLMAssessor) -> None:
180+
"""H-05 regression: cache key must differ between users for identical text.
181+
182+
Before the fix, two users with the same message would collide on the
183+
same cache key, and user A could receive user B's risk assessment.
184+
This is a privacy + safety bug: a correctly non-crisis assessment
185+
for one user could be served for another user whose identical phrase
186+
actually indicates crisis given their context.
187+
"""
188+
text = "same utterance across users"
189+
context = {"intent": "discuss"}
190+
191+
user_a = uuid4()
192+
user_b = uuid4()
193+
194+
key_a = assessor._generate_cache_key(text, context, user_a)
195+
key_b = assessor._generate_cache_key(text, context, user_b)
196+
key_none = assessor._generate_cache_key(text, context)
197+
key_a_again = assessor._generate_cache_key(text, context, user_a)
198+
199+
assert key_a != key_b, (
200+
"H-05 regression: identical text for different users must not "
201+
"produce the same cache key. This risks cross-user PHI leakage."
202+
)
203+
assert key_a != key_none, "user-scoped key must differ from anonymous key"
204+
assert key_a == key_a_again, "same user + same input must be reproducible"
205+
206+
@pytest.mark.asyncio
207+
async def test_crisis_assessments_are_not_cached(self, assessor: LLMAssessor) -> None:
208+
"""H-05 regression: HIGH/CRITICAL results must never be cached.
209+
210+
Caching a transient crisis state could mask a real-time intervention
211+
signal on a subsequent call. The assess() method skips caching when
212+
the assessment resolves to HIGH or CRITICAL.
213+
"""
214+
# Stub the LLM call to return a HIGH-risk assessment every time.
215+
# We return the JSON shape the real parser expects rather than a
216+
# RiskAssessment object — that way the full parse path runs.
217+
async def _fake_call_llm(system_prompt: str, user_prompt: str) -> str:
218+
# Round-trip the assessment through parse_llm_response by returning
219+
# a JSON object the real parser understands.
220+
return (
221+
'{"risk_level":"HIGH","risk_score":0.75,"confidence":0.9,'
222+
'"clinical_summary":"simulated high risk",'
223+
'"risk_factors":[],"protective_factors":[],"immediate_risk":false,'
224+
'"recommended_actions":["monitor"],"warning_signs":[],'
225+
'"contextual_notes":""}'
226+
)
227+
228+
assessor._call_llm = _fake_call_llm # type: ignore[assignment,method-assign]
229+
230+
user_id = uuid4()
231+
text = "I have been thinking about ending it all"
232+
233+
# Two calls with identical inputs — if caching were active on HIGH,
234+
# we'd hit the cache and _call_llm would not run the second time.
235+
call_count = {"n": 0}
236+
original = _fake_call_llm
237+
238+
async def _counting_call(sp: str, up: str) -> str:
239+
call_count["n"] += 1
240+
return await original(sp, up)
241+
242+
assessor._call_llm = _counting_call # type: ignore[assignment,method-assign]
243+
244+
await assessor.assess(text, user_id=user_id)
245+
await assessor.assess(text, user_id=user_id)
246+
247+
assert call_count["n"] == 2, (
248+
"H-05 regression: HIGH-risk assessment must not be cached. "
249+
"The LLM should be called again on the second identical request."
250+
)
251+
177252
def test_get_cached_assessment_miss(self, assessor: LLMAssessor) -> None:
178253
"""Test cache miss returns None."""
179254
cached = assessor._get_cached_assessment("nonexistent_key")

services/safety_service/tests/test_pattern_matcher.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""
22
Tests for pattern_matcher.py - Advanced pattern-based crisis detection.
33
"""
4-
import pytest
54
from decimal import Decimal
65
from uuid import uuid4
6+
7+
import pytest
8+
79
from services.safety_service.src.ml.pattern_matcher import (
810
PatternMatcher,
911
PatternMatcherConfig,
10-
PatternMatch,
1112
PatternType,
1213
)
1314

services/safety_service/tests/test_sentiment_analyzer.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
"""
22
Tests for sentiment_analyzer.py - Clinical sentiment analysis for risk assessment.
33
"""
4-
import pytest
54
from decimal import Decimal
65
from uuid import uuid4
6+
7+
import pytest
8+
79
from services.safety_service.src.ml.sentiment_analyzer import (
10+
EmotionalState,
811
SentimentAnalyzer,
9-
SentimentAnalyzerConfig,
10-
SentimentResult,
1112
SentimentPolarity,
12-
EmotionalState,
1313
)
1414

1515

0 commit comments

Comments
 (0)