Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/.eslint-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,15 @@
"src/features/assessment/components/__tests__/AssessmentResults.test.tsx": 1,
"src/features/assessment/components/__tests__/EnhancedAssessmentQuestion.test.tsx": 1,
"src/features/assessment/hooks/useAssessmentPerformance.ts": 13,
"src/features/assessment/stores/__tests__/answerQuestionValidation.unit.test.ts": 1,
"src/features/assessment/stores/__tests__/assessmentStore.basic.test.ts": 1,
"src/features/assessment/stores/__tests__/assessmentStore.notes.test.ts": 1,
"src/features/assessment/stores/__tests__/assessmentStore.test.ts": 1,
"src/features/assessment/stores/__tests__/crisisDetectionParity.unit.test.ts": 1,
"src/features/assessment/stores/__tests__/crisisTelemetryEmit.unit.test.ts": 1,
"src/features/assessment/stores/__tests__/crisisTelemetryFields.regression.test.ts": 1,
"src/features/assessment/stores/assessmentStore.ts": 26,
"src/features/assessment/types/__tests__/crisisDetectionValidation.unit.test.ts": 1,
"src/features/assessment/types/__tests__/schemas.test.ts": 1,
"src/features/assessment/types/scoring.ts": 2,
"src/features/assessment/types/validation.ts": 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,14 @@ describe('COMPREHENSIVE CLINICAL SCORING VALIDATION - ALL 48 COMBINATIONS', () =

if (expectCrisis) {
expect(finalStore.crisisDetection).toBeTruthy();
// DEBUG-229 / MAINT-226 Decision E — dual-threshold tiers: ≥20 (no Q9)
// is the intervention tier `phq9_severe_score`; 15–19 is the support tier
// `phq9_moderate_severe_score`. Q9>0 always wins as suicidal-ideation.
const expectedTrigger = suicidalResponse > 0
? 'phq9_suicidal_ideation'
: 'phq9_moderate_severe_score';
: score >= CRISIS_THRESHOLDS.PHQ9_SEVERE_THRESHOLD
? 'phq9_severe_score'
: 'phq9_moderate_severe_score';
expect(finalStore.crisisDetection?.primaryTrigger).toBe(expectedTrigger);
// triggerValue: when answerQuestion fires inline crisis for phq9_9 > 0,
// the detection carries the *actual response value* (1, 2, or 3) — that
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* answerQuestionValidation.unit.test.ts — DEBUG-229 (TEST-08 / SEC-06)
*
* `answerQuestion` MUST validate per-question responses against the 0–3 clinical
* scale (`AssessmentResponseSchema`) BEFORE storing/scoring. Out-of-range values
* are rejected fail-loud: the answer is not stored, `error` is set, and the
* inline Q9 crisis check never runs on a corrupt value.
*/
import { jest } from '@jest/globals';

jest.mock('@/core/services/supabase/SupabaseService', () => {
const fn = jest.fn();
return { __esModule: true, default: { trackCrisisDetection: fn }, supabaseService: { trackCrisisDetection: fn } };
});
jest.mock('react-native', () => ({ Alert: { alert: jest.fn() }, Linking: { openURL: jest.fn() } }));
jest.mock('@react-native-async-storage/async-storage');
jest.mock('expo-secure-store');

const mockWellnessBlobs: Record<string, unknown> = {};
jest.mock('@/core/services/security/SecureStorageService', () => ({
__esModule: true,
default: {
storeWellnessBlob: jest.fn(async (key: string, data: unknown) => {
mockWellnessBlobs[key] = data;
return { success: true, operationType: 'store' as const, storageKey: `wellness_async_${key}`, operationTimeMs: 0, dataSize: 0 };
}),
retrieveWellnessBlob: jest.fn(async (key: string) => mockWellnessBlobs[key] ?? null),
deleteWellnessBlob: jest.fn(async (key: string) => { delete mockWellnessBlobs[key]; }),
},
}));

import { useAssessmentStore } from '../assessmentStore';
import supabaseService from '@/core/services/supabase/SupabaseService';
import type { AssessmentResponse } from '../../types/index';

const mockTrack = (supabaseService as any).trackCrisisDetection as jest.Mock;
// Force out-of-range / wrong-type values past the TS signature for the runtime guard test.
const bad = (v: unknown) => v as AssessmentResponse;

describe('DEBUG-229 — answerQuestion 0–3 validation (fail-loud reject)', () => {
beforeEach(async () => {
jest.clearAllMocks();
useAssessmentStore.getState().resetAssessment();
await useAssessmentStore.getState().startAssessment('phq9');
});

it('stores a valid in-range response (0–3) and scores it', async () => {
await useAssessmentStore.getState().answerQuestion('phq9_1', 3 as AssessmentResponse);
const s = useAssessmentStore.getState();
expect(s.answers.find((a) => a.questionId === 'phq9_1')?.response).toBe(3);
expect(s.error).toBeNull();
});

it.each([5, 4, -1, 1.5, NaN, null, undefined, '2'])(
'rejects out-of-range/invalid response %p — not stored, error set',
async (value) => {
await useAssessmentStore.getState().answerQuestion('phq9_1', bad(value));
const s = useAssessmentStore.getState();
expect(s.answers.find((a) => a.questionId === 'phq9_1')).toBeUndefined();
expect(s.error).toBeTruthy();
},
);

it('does NOT fire the inline Q9 crisis check for an out-of-range Q9 value', async () => {
await useAssessmentStore.getState().answerQuestion('phq9_9', bad(5));
const s = useAssessmentStore.getState();
expect(s.answers.find((a) => a.questionId === 'phq9_9')).toBeUndefined();
expect(s.crisisDetection).toBeNull();
expect(mockTrack).not.toHaveBeenCalled();
});

it('a valid Q9>0 still triggers the inline crisis path (guard does not over-reject)', async () => {
await useAssessmentStore.getState().answerQuestion('phq9_9', 1 as AssessmentResponse);
expect(useAssessmentStore.getState().crisisDetection?.primaryTrigger).toBe('phq9_suicidal_ideation');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* crisisDetectionParity.unit.test.ts — DEBUG-229 / MAINT-226 Decision E
*
* Pins the "ONE source of truth" invariant: the store's production crisis path
* (startAssessment → answerQuestion → completeAssessment, read back via
* `store.crisisDetection`) MUST return the same `primaryTrigger` + `severityLevel`
* as the pure `detectCrisis()` in `@/features/crisis/types/safety` for the
* dual-threshold boundary set {14, 15, 19, 20, Q9>0}.
*
* The store's CrisisDetectionService now delegates to the pure function, so a
* future threshold/trigger-vocabulary divergence (the exact defect TEST-07 found)
* cannot silently reappear. Asserts through the production store API — never a
* test-local reimplementation (AC #3).
*/
import { jest } from '@jest/globals';

jest.mock('@/core/services/supabase/SupabaseService', () => {
const fn = jest.fn();
return { __esModule: true, default: { trackCrisisDetection: fn }, supabaseService: { trackCrisisDetection: fn } };
});
jest.mock('react-native', () => ({ Alert: { alert: jest.fn() }, Linking: { openURL: jest.fn() } }));
jest.mock('@react-native-async-storage/async-storage');
jest.mock('expo-secure-store');

const mockWellnessBlobs: Record<string, unknown> = {};
jest.mock('@/core/services/security/SecureStorageService', () => ({
__esModule: true,
default: {
storeWellnessBlob: jest.fn(async (key: string, data: unknown) => {
mockWellnessBlobs[key] = data;
return { success: true, operationType: 'store' as const, storageKey: `wellness_async_${key}`, operationTimeMs: 0, dataSize: 0 };
}),
retrieveWellnessBlob: jest.fn(async (key: string) => mockWellnessBlobs[key] ?? null),
deleteWellnessBlob: jest.fn(async (key: string) => { delete mockWellnessBlobs[key]; }),
},
}));

import { useAssessmentStore } from '../assessmentStore';
import { detectCrisis } from '@/features/crisis/types/safety';
import type { PHQ9Result } from '@/features/assessment/types';
import type { AssessmentResponse } from '../../types/index';

type Ans = { questionId: string; response: AssessmentResponse };

/** Greedy fill q1..q8 to the target, leaving phq9_9 = `q9` (default 0). */
function phq9Answers(targetScore: number, q9 = 0): Ans[] {
const ids = ['phq9_1', 'phq9_2', 'phq9_3', 'phq9_4', 'phq9_5', 'phq9_6', 'phq9_7', 'phq9_8'];
const out: Ans[] = [];
let remaining = targetScore - q9;
for (let i = 0; i < ids.length; i++) {
const left = ids.length - i;
const minNeeded = Math.max(0, remaining - (left - 1) * 3);
const r = Math.max(minNeeded, Math.min(3, remaining)) as AssessmentResponse;
out.push({ questionId: ids[i], response: r });
remaining -= r;
}
out.push({ questionId: 'phq9_9', response: q9 as AssessmentResponse });
return out;
}

const pureResult = (totalScore: number, suicidalIdeation = false): PHQ9Result => ({
totalScore,
severity: 'minimal',
isCrisis: false,
suicidalIdeation,
completedAt: Date.now(),
answers: [],
});

async function runStore(answers: Ans[]) {
const store = useAssessmentStore.getState();
store.resetAssessment();
await store.startAssessment('phq9');
for (const a of answers) {
await useAssessmentStore.getState().answerQuestion(a.questionId, a.response);
}
await useAssessmentStore.getState().completeAssessment();
return useAssessmentStore.getState().crisisDetection;
}

describe('DEBUG-229 — pure detectCrisis ⇄ store CrisisDetectionService parity', () => {
beforeEach(() => {
jest.clearAllMocks();
useAssessmentStore.getState().resetAssessment();
});

it('PHQ-9 14, Q9=0 → both produce no crisis', async () => {
expect(detectCrisis(pureResult(14), 'u1')).toBeNull();
expect(await runStore(phq9Answers(14))).toBeNull();
});

it.each([15, 19, 20, 24])('PHQ-9 %i, Q9=0 → store matches pure (primaryTrigger + severityLevel)', async (score) => {
const pure = detectCrisis(pureResult(score), 'u1');
expect(pure).not.toBeNull();
const fromStore = await runStore(phq9Answers(score));
expect(fromStore).toBeTruthy();
expect(fromStore!.primaryTrigger).toBe(pure!.primaryTrigger);
expect(fromStore!.severityLevel).toBe(pure!.severityLevel);
});

it('PHQ-9 17 with Q9>0 → both report suicidal-ideation primary at "high"', async () => {
const pure = detectCrisis(pureResult(17, true), 'u1');
expect(pure!.primaryTrigger).toBe('phq9_suicidal_ideation');
const fromStore = await runStore(phq9Answers(17, 2));
expect(fromStore!.primaryTrigger).toBe(pure!.primaryTrigger);
expect(fromStore!.severityLevel).toBe(pure!.severityLevel);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,17 @@ describe('DEBUG-218 — crisis_detected carries real severity_bucket + assessmen
});

describe('score-based completed path (completeAssessment) — non-Q9 crises', () => {
it('PHQ-9 ≥20 without Q9 → phq9_moderate_severe_score, severity_bucket "critical", assessment_type "phq9"', async () => {
// DEBUG-229 / MAINT-226 Decision E: ≥20 now emits the intervention-tier
// trigger `phq9_severe_score` (was `phq9_moderate_severe_score` — the store's
// missing-severe-tier bug this work item fixes). severity_bucket stays "critical".
it('PHQ-9 ≥20 without Q9 → phq9_severe_score, severity_bucket "critical", assessment_type "phq9"', async () => {
await useAssessmentStore.getState().startAssessment('phq9');
await answer(phq9(20)); // greedy fill leaves phq9_9 = 0
await useAssessmentStore.getState().completeAssessment();

expect(mockTrack).toHaveBeenCalledTimes(1);
const p = lastPayload();
expect(p.trigger_type).toBe('phq9_moderate_severe_score');
expect(p.trigger_type).toBe('phq9_severe_score');
expect(p.severity_bucket).toBe('critical');
expect(p.assessment_type).toBe('phq9');
expectNoUndefinedString(p);
Expand Down
51 changes: 27 additions & 24 deletions app/src/features/assessment/stores/assessmentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ import {
CRISIS_THRESHOLDS,
ASSESSMENT_RESPONSE_LABELS,
} from '../types/index';
// DEBUG-229 / MAINT-226 Decision E: pure detectCrisis is the single source of
// truth for crisis thresholds + trigger taxonomy; the store delegates to it.
import { detectCrisis as detectCrisisPure } from '@/features/crisis/types/safety';
import { validateSingleResponse } from '../types/schemas';

// Clinical scoring algorithms (validated for 100% accuracy)
const PHQ9_QUESTIONS = [
Expand Down Expand Up @@ -309,35 +313,25 @@ class CrisisDetectionService {
const startTime = Date.now();

try {
let triggerType: CrisisDetection['primaryTrigger'] | null = null;
let triggerValue = result.totalScore;

if (type === 'phq9') {
const phqResult = result as PHQ9Result;
if (phqResult.suicidalIdeation) {
triggerType = 'phq9_suicidal_ideation';
triggerValue = 1; // Indicates suicidal ideation present
} else if (phqResult.totalScore >= CRISIS_THRESHOLDS.PHQ9_CRISIS_SCORE) {
triggerType = 'phq9_moderate_severe_score';
}
} else if (type === 'gad7') {
if (result.totalScore >= CRISIS_THRESHOLDS.GAD7_CRISIS_SCORE) {
triggerType = 'gad7_severe_score';
}
}

if (!triggerType) {
// DEBUG-229 / MAINT-226 Decision E: delegate threshold + trigger logic to the
// pure detectCrisis (single source of truth). This fixes the store's previous
// divergence — it emitted `phq9_moderate_severe_score` even at ≥20 and never
// the intervention-tier `phq9_severe_score`. userId is not part of the
// store-side detection (pass ''); assessmentId is overridden below with the
// live session id so handleCrisisDetection's per-session dedup works.
const pure = detectCrisisPure(result, '');
if (!pure) {
return null;
}

const detection = {
isTriggered: true,
primaryTrigger: triggerType,
secondaryTriggers: [],
// DEBUG-218: populate severityLevel + assessmentType so the score-based
// crisis_detected telemetry carries real buckets (never "undefined").
severityLevel: crisisSeverityLevel(type, result.totalScore),
triggerValue,
primaryTrigger: pure.primaryTrigger,
secondaryTriggers: pure.secondaryTriggers,
// severityLevel + assessmentType populate the score-based crisis_detected
// telemetry buckets (DEBUG-218) — now sourced from the pure function.
severityLevel: pure.severityLevel,
triggerValue: pure.triggerValue,
assessmentType: type,
timestamp: Date.now(),
assessmentId
Expand Down Expand Up @@ -558,6 +552,15 @@ export const useAssessmentStore = create<AssessmentStore>()(
throw new Error('No active assessment session');
}

// DEBUG-229 (TEST-08 / SEC-06): validate the per-question response against
// the 0–3 clinical scale BEFORE storing/scoring. Reject fail-loud — a
// corrupt value must never reach scoring or the inline Q9 crisis check.
const responseCheck = validateSingleResponse(response);
if (!responseCheck.success) {
set({ error: `Invalid response (must be 0–3): ${responseCheck.error}` });
return;
}

try {
const answer: AssessmentAnswer = {
questionId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* crisisDetectionValidation.unit.test.ts — DEBUG-229 / MAINT-226 Decision E
*
* `validateCrisisDetection` must recognize the support tier introduced by the
* dual-threshold fix: a `phq9_moderate_severe_score` detection is valid only in
* the 15–19 band (≥ PHQ9_MODERATE_SEVERE_THRESHOLD, < PHQ9_SEVERE_THRESHOLD).
* The pre-existing `phq9_severe_score` ≥20 guard is unchanged.
*/
import { validateCrisisDetection } from '@/features/assessment/types/validation';
import type { CrisisDetection, CrisisTriggerType } from '@/features/crisis/types/safety';

function detection(
primaryTrigger: CrisisTriggerType,
triggerValue: number,
): CrisisDetection {
return {
id: 'd1',
isTriggered: true,
primaryTrigger,
secondaryTriggers: [],
severityLevel: primaryTrigger === 'phq9_severe_score' ? 'critical' : 'high',
triggerValue,
assessmentType: 'phq9',
timestamp: Date.now(),
assessmentId: 'a1',
userId: 'u1',
detectionResponseTimeMs: 0,
context: { triggeringAnswers: [], timeOfDay: 'morning' },
};
}

describe('DEBUG-229 — validateCrisisDetection support tier', () => {
it('phq9_moderate_severe_score at 17 is valid (within 15–19 support band)', () => {
expect(validateCrisisDetection(detection('phq9_moderate_severe_score', 17)).isValid).toBe(true);
});

it('phq9_moderate_severe_score at 15 (floor) is valid', () => {
expect(validateCrisisDetection(detection('phq9_moderate_severe_score', 15)).isValid).toBe(true);
});

it('phq9_moderate_severe_score at 12 is invalid (below support floor)', () => {
expect(validateCrisisDetection(detection('phq9_moderate_severe_score', 12)).isValid).toBe(false);
});

it('phq9_severe_score at 22 is valid; at 18 is invalid (intervention floor unchanged)', () => {
expect(validateCrisisDetection(detection('phq9_severe_score', 22)).isValid).toBe(true);
expect(validateCrisisDetection(detection('phq9_severe_score', 18)).isValid).toBe(false);
});
});
18 changes: 15 additions & 3 deletions app/src/features/assessment/types/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,11 +359,23 @@ export function validateCrisisDetection(detection: unknown): ValidationResult {

// Validate trigger conditions
if (detection.assessmentType === 'phq9') {
if (detection.primaryTrigger === 'phq9_severe_score' &&
detection.triggerValue < CRISIS_SAFETY_THRESHOLDS.PHQ9_CRISIS_SCORE) {
if (detection.primaryTrigger === 'phq9_severe_score' &&
detection.triggerValue < CRISIS_SAFETY_THRESHOLDS.PHQ9_SEVERE_THRESHOLD) {
errors.push({
code: 'INVALID_PHQ9_CRISIS_TRIGGER',
message: `PHQ-9 score ${detection.triggerValue} below crisis threshold ${CRISIS_SAFETY_THRESHOLDS.PHQ9_CRISIS_SCORE}`,
message: `PHQ-9 score ${detection.triggerValue} below crisis threshold ${CRISIS_SAFETY_THRESHOLDS.PHQ9_SEVERE_THRESHOLD}`,
field: 'triggerValue',
value: detection.triggerValue,
severity: 'critical'
});
}
// DEBUG-229 / MAINT-226 Decision E: the support tier is valid only in the
// 15–19 band (≥ moderate-severe floor, < active-intervention floor).
if (detection.primaryTrigger === 'phq9_moderate_severe_score' &&
detection.triggerValue < CRISIS_SAFETY_THRESHOLDS.PHQ9_MODERATE_SEVERE_THRESHOLD) {
errors.push({
code: 'INVALID_PHQ9_SUPPORT_TRIGGER',
message: `PHQ-9 score ${detection.triggerValue} below support threshold ${CRISIS_SAFETY_THRESHOLDS.PHQ9_MODERATE_SEVERE_THRESHOLD}`,
field: 'triggerValue',
value: detection.triggerValue,
severity: 'critical'
Expand Down
Loading
Loading