KevinIsInCoding Claude Sonnet 4.6 commited on
Commit
9e87f14
·
unverified ·
1 Parent(s): 9c9268b

test(wave-3): deterministic eligibility and trials_api tests (#18)

Browse files

tests/agents/test_eligibility.py (37 tests):
- _evaluate_deterministic: every operator (<=, >=, ==, !=, in, not_in,
between) × pass/fail; output shape (confidence=high, patient_value
stringified, reason format, unit inclusion, TypeError→FAIL)
- _resolve_patient_value: patient.* fields, platform.* with/without data,
unknown key
- _compute_overall: precedence rule FAIL>UNKNOWN>PASS, empty list→PASS

tests/test_trials_api.py (30 tests):
- _flatten_and_rank: sort order, None-distance sorts last, nearest_sites
capped at 5, central-contact phone fallback, required key presence
- search_trials_api aggFilters: all 8 case-matrix branches from the
inline comment in trials_api.py
- search_trials_api pagination: nextPageToken forwarded, multi-page
accumulation, empty response
- search_trials_api retry: succeeds after 1 failure, raises after 3

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

tests/agents/test_eligibility.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for deterministic logic in agents/eligibility.py.
2
+
3
+ Covers _evaluate_deterministic, _resolve_patient_value, and _compute_overall.
4
+ No mocks needed — all three functions are pure computation.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import pytest
9
+
10
+ from agents.eligibility import (
11
+ _compute_overall,
12
+ _evaluate_deterministic,
13
+ _resolve_patient_value,
14
+ )
15
+ from models import (
16
+ CriterionAssessment,
17
+ CriterionVerdict,
18
+ EligibilityCriterion,
19
+ ParsedConstraint,
20
+ PatientProfile,
21
+ )
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Helpers
26
+ # ---------------------------------------------------------------------------
27
+
28
+ def make_criterion(
29
+ key: str = "age_years",
30
+ operator: str = ">=",
31
+ value=18,
32
+ unit: str | None = None,
33
+ ctype: str = "inclusion",
34
+ description: str = "Test criterion",
35
+ raw_criteria: str = "Raw criterion text",
36
+ ) -> EligibilityCriterion:
37
+ return EligibilityCriterion(
38
+ key=key,
39
+ type=ctype,
40
+ description=description,
41
+ raw_criteria=raw_criteria,
42
+ constraint=ParsedConstraint(key=key, operator=operator, value=value, unit=unit),
43
+ )
44
+
45
+
46
+ def make_assessment(verdict: CriterionVerdict) -> CriterionAssessment:
47
+ c = make_criterion()
48
+ return CriterionAssessment(
49
+ criterion=c,
50
+ verdict=verdict,
51
+ reason="test",
52
+ patient_value="42",
53
+ confidence="high",
54
+ )
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # _evaluate_deterministic — every operator × pass/fail
59
+ # ---------------------------------------------------------------------------
60
+
61
+ class TestEvaluateDeterministic:
62
+ # --- >= ---
63
+ def test_gte_passes_when_equal(self):
64
+ a = _evaluate_deterministic(make_criterion(operator=">=", value=18), 18)
65
+ assert a.verdict == CriterionVerdict.PASS
66
+
67
+ def test_gte_passes_when_above(self):
68
+ a = _evaluate_deterministic(make_criterion(operator=">=", value=18), 52)
69
+ assert a.verdict == CriterionVerdict.PASS
70
+
71
+ def test_gte_fails_when_below(self):
72
+ a = _evaluate_deterministic(make_criterion(operator=">=", value=18), 16)
73
+ assert a.verdict == CriterionVerdict.FAIL
74
+
75
+ # --- <= ---
76
+ def test_lte_passes_when_equal(self):
77
+ a = _evaluate_deterministic(make_criterion(operator="<=", value=75), 75)
78
+ assert a.verdict == CriterionVerdict.PASS
79
+
80
+ def test_lte_passes_when_below(self):
81
+ a = _evaluate_deterministic(make_criterion(operator="<=", value=75), 52)
82
+ assert a.verdict == CriterionVerdict.PASS
83
+
84
+ def test_lte_fails_when_above(self):
85
+ a = _evaluate_deterministic(make_criterion(operator="<=", value=75), 80)
86
+ assert a.verdict == CriterionVerdict.FAIL
87
+
88
+ # --- == ---
89
+ def test_eq_passes(self):
90
+ a = _evaluate_deterministic(make_criterion(operator="==", value=0), 0)
91
+ assert a.verdict == CriterionVerdict.PASS
92
+
93
+ def test_eq_fails(self):
94
+ a = _evaluate_deterministic(make_criterion(operator="==", value=0), 1)
95
+ assert a.verdict == CriterionVerdict.FAIL
96
+
97
+ # --- != ---
98
+ def test_ne_passes(self):
99
+ a = _evaluate_deterministic(make_criterion(operator="!=", value=0), 1)
100
+ assert a.verdict == CriterionVerdict.PASS
101
+
102
+ def test_ne_fails(self):
103
+ a = _evaluate_deterministic(make_criterion(operator="!=", value=0), 0)
104
+ assert a.verdict == CriterionVerdict.FAIL
105
+
106
+ # --- in ---
107
+ def test_in_passes(self):
108
+ a = _evaluate_deterministic(make_criterion(operator="in", value=[0, 1]), 1)
109
+ assert a.verdict == CriterionVerdict.PASS
110
+
111
+ def test_in_fails(self):
112
+ a = _evaluate_deterministic(make_criterion(operator="in", value=[0, 1]), 2)
113
+ assert a.verdict == CriterionVerdict.FAIL
114
+
115
+ # --- not_in ---
116
+ def test_not_in_passes(self):
117
+ a = _evaluate_deterministic(make_criterion(operator="not_in", value=[2, 3]), 1)
118
+ assert a.verdict == CriterionVerdict.PASS
119
+
120
+ def test_not_in_fails(self):
121
+ a = _evaluate_deterministic(make_criterion(operator="not_in", value=[2, 3]), 2)
122
+ assert a.verdict == CriterionVerdict.FAIL
123
+
124
+ # --- between ---
125
+ def test_between_passes_at_lower_bound(self):
126
+ a = _evaluate_deterministic(make_criterion(operator="between", value=[18, 75]), 18)
127
+ assert a.verdict == CriterionVerdict.PASS
128
+
129
+ def test_between_passes_at_upper_bound(self):
130
+ a = _evaluate_deterministic(make_criterion(operator="between", value=[18, 75]), 75)
131
+ assert a.verdict == CriterionVerdict.PASS
132
+
133
+ def test_between_passes_mid(self):
134
+ a = _evaluate_deterministic(make_criterion(operator="between", value=[18, 75]), 52)
135
+ assert a.verdict == CriterionVerdict.PASS
136
+
137
+ def test_between_fails_below(self):
138
+ a = _evaluate_deterministic(make_criterion(operator="between", value=[18, 75]), 17)
139
+ assert a.verdict == CriterionVerdict.FAIL
140
+
141
+ def test_between_fails_above(self):
142
+ a = _evaluate_deterministic(make_criterion(operator="between", value=[18, 75]), 76)
143
+ assert a.verdict == CriterionVerdict.FAIL
144
+
145
+ # --- output shape ---
146
+ def test_confidence_is_high(self):
147
+ a = _evaluate_deterministic(make_criterion(operator=">=", value=18), 52)
148
+ assert a.confidence == "high"
149
+
150
+ def test_patient_value_stringified(self):
151
+ a = _evaluate_deterministic(make_criterion(operator=">=", value=18), 52)
152
+ assert a.patient_value == "52"
153
+
154
+ def test_reason_contains_operator_and_value(self):
155
+ a = _evaluate_deterministic(make_criterion(operator=">=", value=18), 52)
156
+ assert ">=" in a.reason
157
+ assert "18" in a.reason
158
+
159
+ def test_reason_includes_unit_when_present(self):
160
+ c = make_criterion(operator=">=", value=60, unit="mL/min")
161
+ a = _evaluate_deterministic(c, 65)
162
+ assert "mL/min" in a.reason
163
+
164
+ def test_type_error_in_comparison_yields_fail(self):
165
+ # Comparing int to a list value that can't be compared numerically
166
+ c = make_criterion(operator=">=", value="not-a-number")
167
+ a = _evaluate_deterministic(c, 52)
168
+ assert a.verdict == CriterionVerdict.FAIL
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # _resolve_patient_value
173
+ # ---------------------------------------------------------------------------
174
+
175
+ class TestResolvePatientValue:
176
+ def test_patient_age_resolves(self, als_patient):
177
+ val, found = _resolve_patient_value("age_years", als_patient, None)
178
+ assert found is True
179
+ assert val == 52
180
+
181
+ def test_patient_onset_months_resolves(self, als_patient):
182
+ val, found = _resolve_patient_value("symptom_onset_months", als_patient, None)
183
+ assert found is True
184
+ assert val == 18
185
+
186
+ def test_patient_diagnosis_months_resolves(self, als_patient):
187
+ val, found = _resolve_patient_value("diagnosis_months", als_patient, None)
188
+ assert found is True
189
+ assert val == 12
190
+
191
+ def test_unknown_key_returns_not_found(self, als_patient):
192
+ val, found = _resolve_patient_value("nonexistent_key_xyz", als_patient, None)
193
+ assert found is False
194
+ assert val is None
195
+
196
+ def test_platform_key_returns_not_found_without_data(self, als_patient):
197
+ val, found = _resolve_patient_value("ecog_status", als_patient, None)
198
+ assert found is False
199
+
200
+ def test_platform_key_resolves_with_data(self, als_patient):
201
+ platform = {"ecog_status": 1}
202
+ val, found = _resolve_patient_value("ecog_status", als_patient, platform)
203
+ assert found is True
204
+ assert val == 1
205
+
206
+ def test_platform_key_missing_from_dict_returns_not_found(self, als_patient):
207
+ platform = {"other_field": 99}
208
+ val, found = _resolve_patient_value("ecog_status", als_patient, platform)
209
+ assert found is False
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # _compute_overall
214
+ # ---------------------------------------------------------------------------
215
+
216
+ class TestComputeOverall:
217
+ def test_all_pass_returns_pass(self):
218
+ assessments = [make_assessment(CriterionVerdict.PASS)] * 3
219
+ assert _compute_overall(assessments) == CriterionVerdict.PASS
220
+
221
+ def test_any_fail_returns_fail(self):
222
+ assessments = [
223
+ make_assessment(CriterionVerdict.PASS),
224
+ make_assessment(CriterionVerdict.FAIL),
225
+ make_assessment(CriterionVerdict.UNKNOWN),
226
+ ]
227
+ assert _compute_overall(assessments) == CriterionVerdict.FAIL
228
+
229
+ def test_fail_takes_precedence_over_unknown(self):
230
+ assessments = [
231
+ make_assessment(CriterionVerdict.UNKNOWN),
232
+ make_assessment(CriterionVerdict.FAIL),
233
+ ]
234
+ assert _compute_overall(assessments) == CriterionVerdict.FAIL
235
+
236
+ def test_unknown_without_fail_returns_unknown(self):
237
+ assessments = [
238
+ make_assessment(CriterionVerdict.PASS),
239
+ make_assessment(CriterionVerdict.UNKNOWN),
240
+ ]
241
+ assert _compute_overall(assessments) == CriterionVerdict.UNKNOWN
242
+
243
+ def test_empty_list_returns_pass(self):
244
+ # No criteria → no objections → eligible
245
+ assert _compute_overall([]) == CriterionVerdict.PASS
246
+
247
+ def test_single_fail_returns_fail(self):
248
+ assert _compute_overall([make_assessment(CriterionVerdict.FAIL)]) == CriterionVerdict.FAIL
249
+
250
+ def test_single_unknown_returns_unknown(self):
251
+ assert _compute_overall([make_assessment(CriterionVerdict.UNKNOWN)]) == CriterionVerdict.UNKNOWN
tests/test_trials_api.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for trials_api.py.
2
+
3
+ _flatten_and_rank: pure dict reshaping — no mocks.
4
+ search_trials_api: HTTP is mocked with pytest-httpx.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ from trials_api import _flatten_and_rank, search_trials_api
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Minimal study builder
18
+ # ---------------------------------------------------------------------------
19
+
20
+ def make_study(
21
+ nct_id: str = "NCT00000001",
22
+ title: str = "Test Trial",
23
+ phase: list[str] | None = None,
24
+ sponsor: str = "ACME Pharma",
25
+ locations: list[dict] | None = None,
26
+ eligibility_criteria: str = "Inclusion:\n- Age 18-75",
27
+ ) -> dict:
28
+ return {
29
+ "protocolSection": {
30
+ "identificationModule": {"nctId": nct_id, "briefTitle": title},
31
+ "descriptionModule": {"briefSummary": "A test trial."},
32
+ "eligibilityModule": {
33
+ "eligibilityCriteria": eligibility_criteria,
34
+ "minimumAge": "18 Years",
35
+ "maximumAge": "75 Years",
36
+ "sex": "ALL",
37
+ "healthyVolunteers": "No",
38
+ "stdAges": ["ADULT"],
39
+ },
40
+ "contactsLocationsModule": {
41
+ "centralContacts": [{"phone": "617-555-0100", "email": "pi@hospital.org"}],
42
+ "overallOfficials": [{"name": "Dr. Smith", "role": "PRINCIPAL_INVESTIGATOR"}],
43
+ "locations": locations or [],
44
+ },
45
+ "sponsorCollaboratorsModule": {"leadSponsor": {"name": sponsor}},
46
+ "designModule": {
47
+ "studyType": "INTERVENTIONAL",
48
+ "phases": phase or ["PHASE2"],
49
+ "enrollmentInfo": {"count": 50},
50
+ },
51
+ "conditionsModule": {"conditions": ["ALS"], "keywords": ["motor neuron"]},
52
+ "armsInterventionsModule": {"interventions": []},
53
+ }
54
+ }
55
+
56
+
57
+ def make_location(lat: float, lon: float, city: str = "Boston", state: str = "MA") -> dict:
58
+ return {
59
+ "facility": f"{city} Medical Center",
60
+ "city": city,
61
+ "state": state,
62
+ "geoPoint": {"lat": lat, "lon": lon},
63
+ "contacts": [{"phone": "617-555-0200", "email": "site@hospital.org"}],
64
+ }
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # _flatten_and_rank — pure logic, no HTTP
69
+ # ---------------------------------------------------------------------------
70
+
71
+ class TestFlattenAndRank:
72
+ PATIENT_LAT = 42.3370
73
+ PATIENT_LON = -71.1061 # Boston
74
+
75
+ def test_empty_input_returns_empty(self):
76
+ assert _flatten_and_rank([], self.PATIENT_LAT, self.PATIENT_LON) == []
77
+
78
+ def test_nct_id_extracted(self):
79
+ studies = [make_study(nct_id="NCT12345678")]
80
+ result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
81
+ assert result[0]["nct_id"] == "NCT12345678"
82
+
83
+ def test_title_extracted(self):
84
+ studies = [make_study(title="My Trial")]
85
+ result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
86
+ assert result[0]["title"] == "My Trial"
87
+
88
+ def test_sponsor_extracted(self):
89
+ studies = [make_study(sponsor="Test Corp")]
90
+ result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
91
+ assert result[0]["sponsor"] == "Test Corp"
92
+
93
+ def test_no_locations_yields_none_closest(self):
94
+ studies = [make_study()]
95
+ result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
96
+ assert result[0]["closest_site_miles"] is None
97
+
98
+ def test_location_distance_computed(self):
99
+ loc = make_location(lat=42.3370, lon=-71.1061) # same as patient
100
+ studies = [make_study(locations=[loc])]
101
+ result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
102
+ assert result[0]["closest_site_miles"] == 0.0
103
+
104
+ def test_sorted_by_closest_distance(self):
105
+ near_loc = make_location(lat=42.3370, lon=-71.1061, city="Boston") # 0 mi
106
+ far_loc = make_location(lat=40.7128, lon=-74.0060, city="NewYork") # ~190 mi
107
+ near_study = make_study(nct_id="NCT00000001", locations=[near_loc])
108
+ far_study = make_study(nct_id="NCT00000002", locations=[far_loc])
109
+ # Pass far first to confirm sort works
110
+ result = _flatten_and_rank([far_study, near_study], self.PATIENT_LAT, self.PATIENT_LON)
111
+ assert result[0]["nct_id"] == "NCT00000001"
112
+ assert result[1]["nct_id"] == "NCT00000002"
113
+
114
+ def test_no_location_sorts_last(self):
115
+ loc = make_location(lat=42.3370, lon=-71.1061)
116
+ study_with = make_study(nct_id="NCT00000001", locations=[loc])
117
+ study_without = make_study(nct_id="NCT00000002", locations=[])
118
+ result = _flatten_and_rank([study_without, study_with], self.PATIENT_LAT, self.PATIENT_LON)
119
+ assert result[0]["nct_id"] == "NCT00000001"
120
+ assert result[1]["nct_id"] == "NCT00000002"
121
+
122
+ def test_nearest_sites_capped_at_five(self):
123
+ locs = [make_location(lat=42.3370 + i * 0.01, lon=-71.1061, city=f"City{i}") for i in range(8)]
124
+ studies = [make_study(locations=locs)]
125
+ result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
126
+ assert len(result[0]["nearest_sites"]) == 5
127
+
128
+ def test_site_label_contains_city_and_distance(self):
129
+ loc = make_location(lat=42.3370, lon=-71.1061, city="Boston")
130
+ studies = [make_study(locations=[loc])]
131
+ result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
132
+ label = result[0]["nearest_sites"][0]["label"]
133
+ assert "Boston" in label
134
+ assert "mi" in label
135
+
136
+ def test_central_contact_phone_fallback(self):
137
+ # Site has no phone — should fall back to central contact
138
+ loc = {
139
+ "facility": "No-Phone Clinic",
140
+ "city": "Cambridge",
141
+ "state": "MA",
142
+ "geoPoint": {"lat": 42.37, "lon": -71.10},
143
+ "contacts": [],
144
+ }
145
+ studies = [make_study(locations=[loc])]
146
+ result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
147
+ assert result[0]["nearest_sites"][0]["phone"] == "617-555-0100"
148
+
149
+ def test_required_keys_present(self):
150
+ studies = [make_study()]
151
+ result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
152
+ row = result[0]
153
+ for key in ("nct_id", "title", "phase", "sponsor", "summary", "eligibility",
154
+ "closest_site_miles", "nearest_sites", "interventions"):
155
+ assert key in row, f"missing key: {key}"
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # search_trials_api — aggFilters param construction
160
+ # ---------------------------------------------------------------------------
161
+
162
+ def _api_response(studies: list[dict], next_token: str | None = None) -> dict:
163
+ body: dict = {"studies": studies}
164
+ if next_token:
165
+ body["nextPageToken"] = next_token
166
+ return body
167
+
168
+
169
+ class TestSearchTrialsApiAggFilters:
170
+ BASE_URL = "https://clinicaltrials.gov/api/v2/studies"
171
+
172
+ def _get_params(self, httpx_mock, **kwargs) -> dict:
173
+ """Call search_trials_api with given kwargs, return query params of the captured request."""
174
+ httpx_mock.add_response(json=_api_response([]))
175
+ search_trials_api(condition="ALS", lat=42.33, lon=-71.10, **kwargs)
176
+ return dict(httpx_mock.get_requests()[0].url.params)
177
+
178
+ def test_default_interventional_all_phases(self, httpx_mock):
179
+ params = self._get_params(httpx_mock)
180
+ assert params["aggFilters"] == "studyType:int"
181
+
182
+ def test_interventional_specific_phases(self, httpx_mock):
183
+ params = self._get_params(httpx_mock, phases=["2", "3"])
184
+ assert params["aggFilters"] == "phase:2 3"
185
+
186
+ def test_interventional_na_only_maps_to_int(self, httpx_mock):
187
+ # "na" is not a numbered phase — falls through to studyType:int
188
+ params = self._get_params(httpx_mock, phases=["na"])
189
+ assert params["aggFilters"] == "studyType:int"
190
+
191
+ def test_interventional_mixed_phases_strips_na(self, httpx_mock):
192
+ params = self._get_params(httpx_mock, phases=["2", "na"])
193
+ assert params["aggFilters"] == "phase:2"
194
+
195
+ def test_observational_study_type(self, httpx_mock):
196
+ params = self._get_params(httpx_mock, study_type="OBSERVATIONAL")
197
+ assert params["aggFilters"] == "studyType:obs"
198
+
199
+ def test_eap_no_phases(self, httpx_mock):
200
+ params = self._get_params(httpx_mock, study_type="EXPANDED_ACCESS")
201
+ assert params["aggFilters"] == "studyType:exp"
202
+
203
+ def test_eap_with_numbered_phases(self, httpx_mock):
204
+ params = self._get_params(httpx_mock, study_type="EXPANDED_ACCESS", phases=["2", "3"])
205
+ assert params["aggFilters"] == "studyType:exp,phase:2 3"
206
+
207
+ def test_eap_with_na_only_no_phase_filter(self, httpx_mock):
208
+ params = self._get_params(httpx_mock, study_type="EXPANDED_ACCESS", phases=["na"])
209
+ assert params["aggFilters"] == "studyType:exp"
210
+
211
+ def test_status_filter_recruiting_for_interventional(self, httpx_mock):
212
+ params = self._get_params(httpx_mock)
213
+ assert params["filter.overallStatus"] == "RECRUITING"
214
+
215
+ def test_status_filter_available_for_eap(self, httpx_mock):
216
+ params = self._get_params(httpx_mock, study_type="EXPANDED_ACCESS")
217
+ assert params["filter.overallStatus"] == "AVAILABLE"
218
+
219
+ def test_geo_filter_formatted(self, httpx_mock):
220
+ params = self._get_params(httpx_mock, radius_miles=50)
221
+ assert params["filter.geo"] == "distance(42.33,-71.1,50mi)"
222
+
223
+ def test_condition_passed_through(self, httpx_mock):
224
+ params = self._get_params(httpx_mock)
225
+ assert params["query.cond"] == "ALS"
226
+
227
+
228
+ class TestSearchTrialsApiPagination:
229
+ def test_follows_next_page_token(self, httpx_mock):
230
+ page1 = _api_response([make_study("NCT00000001")], next_token="token-abc")
231
+ page2 = _api_response([make_study("NCT00000002")])
232
+ httpx_mock.add_response(json=page1)
233
+ httpx_mock.add_response(json=page2)
234
+
235
+ results = search_trials_api("ALS", lat=42.33, lon=-71.10)
236
+ assert len(results) == 2
237
+
238
+ def test_second_request_includes_page_token(self, httpx_mock):
239
+ page1 = _api_response([make_study()], next_token="token-xyz")
240
+ page2 = _api_response([])
241
+ httpx_mock.add_response(json=page1)
242
+ httpx_mock.add_response(json=page2)
243
+
244
+ search_trials_api("ALS", lat=42.33, lon=-71.10)
245
+ second_req = httpx_mock.get_requests()[1]
246
+ assert second_req.url.params["pageToken"] == "token-xyz"
247
+
248
+ def test_empty_response_returns_empty_list(self, httpx_mock):
249
+ httpx_mock.add_response(json=_api_response([]))
250
+ results = search_trials_api("ALS", lat=42.33, lon=-71.10)
251
+ assert results == []
252
+
253
+
254
+ class TestSearchTrialsApiRetry:
255
+ def test_retries_on_http_error_then_succeeds(self, httpx_mock):
256
+ httpx_mock.add_response(status_code=500)
257
+ httpx_mock.add_response(json=_api_response([make_study()]))
258
+
259
+ results = search_trials_api("ALS", lat=42.33, lon=-71.10)
260
+ assert len(results) == 1
261
+ assert len(httpx_mock.get_requests()) == 2
262
+
263
+ def test_raises_after_three_failures(self, httpx_mock):
264
+ for _ in range(3):
265
+ httpx_mock.add_response(status_code=503)
266
+
267
+ with pytest.raises(httpx.HTTPStatusError):
268
+ search_trials_api("ALS", lat=42.33, lon=-71.10)