33
44import httpx
55import pytest
6+ from sqlalchemy .ext .asyncio import AsyncConnection
67
78from core .errors import NoResultsError
9+ from routers .dependencies import Pagination
10+ from routers .openml .tasks import TaskStatusFilter , list_tasks
811
912
1013async def test_list_tasks_default (py_api : httpx .AsyncClient ) -> None :
@@ -36,60 +39,138 @@ async def test_list_tasks_get(py_api: httpx.AsyncClient) -> None:
3639 assert isinstance (response .json (), list )
3740
3841
39- async def test_list_tasks_filter_type (py_api : httpx .AsyncClient ) -> None :
42+ @pytest .mark .parametrize (
43+ ("limit" , "offset" , "expected_status" , "expected_max_results" ),
44+ [
45+ (- 10 , 0 , HTTPStatus .NOT_FOUND , 0 ), # negative limit clamped to 0 -> No results
46+ (5 , - 10 , HTTPStatus .OK , 5 ), # negative offset clamped to 0 -> First 5 results
47+ ],
48+ ids = ["negative_limit" , "negative_offset" ],
49+ )
50+ async def test_list_tasks_negative_pagination_safely_clamped (
51+ limit : int ,
52+ offset : int ,
53+ expected_status : int ,
54+ expected_max_results : int ,
55+ py_api : httpx .AsyncClient ,
56+ ) -> None :
57+ """Negative pagination values are safely clamped to 0 instead of causing 500 errors.
58+
59+ A limit clamped to 0 returns a 482 NoResultsError (404 Not Found).
60+ An offset clamped to 0 simply returns the first page of results (200 OK).
61+
62+ Note: This remains an HTTP-level (py_api) test to ensure end-to-end safety is
63+ preserved, especially if validation logic is moved to the Pydantic layer in
64+ the future. Then error will be 422 Unprocessable Entity.
65+ """
66+ response = await py_api .post (
67+ "/tasks/list" ,
68+ json = {"pagination" : {"limit" : limit , "offset" : offset }},
69+ )
70+ assert response .status_code == expected_status
71+ if expected_status == HTTPStatus .OK :
72+ assert len (response .json ()) <= expected_max_results
73+ else :
74+ error = response .json ()
75+ assert error ["type" ] == NoResultsError .uri
76+
77+
78+ @pytest .mark .parametrize (
79+ "pagination_override" ,
80+ [
81+ {"limit" : "abc" , "offset" : 0 }, # Invalid type
82+ {"limit" : 5 , "offset" : "xyz" }, # Invalid type
83+ ],
84+ ids = ["bad_limit_type" , "bad_offset_type" ],
85+ )
86+ async def test_list_tasks_invalid_pagination_type (
87+ pagination_override : dict [str , Any ], py_api : httpx .AsyncClient
88+ ) -> None :
89+ """Invalid pagination types return 422 Unprocessable Entity."""
90+ response = await py_api .post (
91+ "/tasks/list" ,
92+ json = {"pagination" : pagination_override },
93+ )
94+ assert response .status_code == HTTPStatus .UNPROCESSABLE_ENTITY
95+
96+
97+ @pytest .mark .parametrize (
98+ "value" ,
99+ ["1...2" , "abc" ],
100+ ids = ["triple_dot" , "non_numeric" ],
101+ )
102+ async def test_list_tasks_invalid_range (value : str , py_api : httpx .AsyncClient ) -> None :
103+ """Invalid number_instances format returns 422 Unprocessable Entity."""
104+ response = await py_api .post ("/tasks/list" , json = {"number_instances" : value })
105+ assert response .status_code == HTTPStatus .UNPROCESSABLE_ENTITY
106+
107+
108+ @pytest .mark .parametrize (
109+ "payload" ,
110+ [
111+ {"tag" : "!@#$% " }, # SystemString64 regex mismatch
112+ {"data_name" : "!@#$% " }, # CasualString128 regex mismatch
113+ {"task_id" : []}, # min_length=1 violation
114+ {"data_id" : []}, # min_length=1 violation
115+ ],
116+ ids = ["bad_tag_format" , "bad_data_name_format" , "empty_task_ids" , "empty_data_ids" ],
117+ )
118+ async def test_list_tasks_invalid_inputs (
119+ payload : dict [str , Any ], py_api : httpx .AsyncClient
120+ ) -> None :
121+ """Malformed inputs violating Pydantic/FastAPI constraints return 422."""
122+ response = await py_api .post ("/tasks/list" , json = payload )
123+ assert response .status_code == HTTPStatus .UNPROCESSABLE_ENTITY
124+
125+
126+ # ── Direct call tests: list_tasks ──
127+
128+
129+ async def test_list_tasks_filter_type (expdb_test : AsyncConnection ) -> None :
40130 """Filter by task_type_id returns only tasks of that type."""
41- response = await py_api .post ("/tasks/list" , json = {"task_type_id" : 1 })
42- assert response .status_code == HTTPStatus .OK
43- tasks = response .json ()
131+ tasks = await list_tasks (pagination = Pagination (), task_type_id = 1 , expdb = expdb_test )
44132 assert len (tasks ) > 0
45133 assert all (t ["task_type_id" ] == 1 for t in tasks )
46134
47135
48- async def test_list_tasks_filter_tag (py_api : httpx . AsyncClient ) -> None :
136+ async def test_list_tasks_filter_tag (expdb_test : AsyncConnection ) -> None :
49137 """Filter by tag returns only tasks with that tag."""
50- response = await py_api .post ("/tasks/list" , json = {"tag" : "OpenML100" })
51- assert response .status_code == HTTPStatus .OK
52- tasks = response .json ()
138+ tasks = await list_tasks (pagination = Pagination (), tag = "OpenML100" , expdb = expdb_test )
53139 assert len (tasks ) > 0
54140 assert all ("OpenML100" in t ["tag" ] for t in tasks )
55141
56142
57143@pytest .mark .parametrize ("task_id" , [1 , 59 , [1 , 2 , 3 ]])
58144async def test_list_tasks_filter_task_id (
59- task_id : int | list [int ], py_api : httpx . AsyncClient
145+ task_id : int | list [int ], expdb_test : AsyncConnection
60146) -> None :
61147 """Filter by task_id returns only those tasks."""
62148 ids = [task_id ] if isinstance (task_id , int ) else task_id
63- response = await py_api .post ("/tasks/list" , json = {"task_id" : ids })
64- assert response .status_code == HTTPStatus .OK
65- returned_ids = {t ["task_id" ] for t in response .json ()}
149+ tasks = await list_tasks (pagination = Pagination (), task_id = ids , expdb = expdb_test )
150+ returned_ids = {t ["task_id" ] for t in tasks }
66151 assert returned_ids == set (ids )
67152
68153
69- async def test_list_tasks_filter_data_id (py_api : httpx . AsyncClient ) -> None :
154+ async def test_list_tasks_filter_data_id (expdb_test : AsyncConnection ) -> None :
70155 """Filter by data_id returns only tasks that use that dataset."""
71156 data_id = 10
72- response = await py_api .post ("/tasks/list" , json = {"data_id" : [data_id ]})
73- assert response .status_code == HTTPStatus .OK
74- tasks = response .json ()
157+ tasks = await list_tasks (pagination = Pagination (), data_id = [data_id ], expdb = expdb_test )
75158 assert len (tasks ) > 0
76159 assert all (t ["did" ] == data_id for t in tasks )
77160
78161
79- async def test_list_tasks_filter_data_name (py_api : httpx . AsyncClient ) -> None :
162+ async def test_list_tasks_filter_data_name (expdb_test : AsyncConnection ) -> None :
80163 """Filter by data_name returns only tasks whose dataset matches."""
81- response = await py_api .post ("/tasks/list" , json = {"data_name" : "mfeat-pixel" })
82- assert response .status_code == HTTPStatus .OK
83- tasks = response .json ()
164+ tasks = await list_tasks (pagination = Pagination (), data_name = "mfeat-pixel" , expdb = expdb_test )
84165 assert len (tasks ) > 0
85166 assert all (t ["name" ] == "mfeat-pixel" for t in tasks )
86167
87168
88- async def test_list_tasks_filter_status_deactivated (py_api : httpx . AsyncClient ) -> None :
169+ async def test_list_tasks_filter_status_deactivated (expdb_test : AsyncConnection ) -> None :
89170 """Filter by status='deactivated' returns tasks with that status."""
90- response = await py_api . post ( "/tasks/list" , json = { "status" : "deactivated" })
91- assert response . status_code == HTTPStatus . OK
92- tasks = response . json ( )
171+ tasks = await list_tasks (
172+ pagination = Pagination (), status = TaskStatusFilter . DEACTIVATED , expdb = expdb_test
173+ )
93174 assert len (tasks ) > 0
94175 assert all (t ["status" ] == "deactivated" for t in tasks )
95176
@@ -98,114 +179,56 @@ async def test_list_tasks_filter_status_deactivated(py_api: httpx.AsyncClient) -
98179 ("limit" , "offset" ),
99180 [(5 , 0 ), (10 , 0 ), (5 , 5 )],
100181)
101- async def test_list_tasks_pagination (limit : int , offset : int , py_api : httpx . AsyncClient ) -> None :
182+ async def test_list_tasks_pagination (limit : int , offset : int , expdb_test : AsyncConnection ) -> None :
102183 """Pagination limit and offset are respected."""
103- response = await py_api .post (
104- "/tasks/list" ,
105- json = {"pagination" : {"limit" : limit , "offset" : offset }},
106- )
107- assert response .status_code == HTTPStatus .OK
108- assert len (response .json ()) <= limit
184+ tasks = await list_tasks (pagination = Pagination (limit = limit , offset = offset ), expdb = expdb_test )
185+ assert len (tasks ) <= limit
109186
110187
111- async def test_list_tasks_pagination_order_stable (py_api : httpx . AsyncClient ) -> None :
188+ async def test_list_tasks_pagination_order_stable (expdb_test : AsyncConnection ) -> None :
112189 """Results are ordered by task_id — consecutive pages are in ascending order."""
113- r1 = await py_api . post ( "/tasks/list" , json = { " pagination" : { " limit" : 5 , " offset" : 0 }} )
114- r2 = await py_api . post ( "/tasks/list" , json = { " pagination" : { " limit" : 5 , " offset" : 5 }} )
115- ids1 = [t ["task_id" ] for t in r1 . json () ]
116- ids2 = [t ["task_id" ] for t in r2 . json () ]
190+ tasks1 = await list_tasks ( pagination = Pagination ( limit = 5 , offset = 0 ), expdb = expdb_test )
191+ tasks2 = await list_tasks ( pagination = Pagination ( limit = 5 , offset = 5 ), expdb = expdb_test )
192+ ids1 = [t ["task_id" ] for t in tasks1 ]
193+ ids2 = [t ["task_id" ] for t in tasks2 ]
117194 assert ids1 == sorted (ids1 )
118195 assert ids2 == sorted (ids2 )
119196 if ids1 and ids2 :
120197 assert max (ids1 ) < min (ids2 )
121198
122199
123- async def test_list_tasks_number_instances_range (py_api : httpx . AsyncClient ) -> None :
200+ async def test_list_tasks_number_instances_range (expdb_test : AsyncConnection ) -> None :
124201 """number_instances range filter returns tasks whose dataset matches."""
125202 min_instances , max_instances = 100 , 1000
126- response = await py_api .post (
127- "/tasks/list" ,
128- json = {"number_instances" : f"{ min_instances } ..{ max_instances } " },
203+ tasks = await list_tasks (
204+ pagination = Pagination (),
205+ number_instances = f"{ min_instances } ..{ max_instances } " ,
206+ expdb = expdb_test ,
129207 )
130- assert response .status_code == HTTPStatus .OK
131- tasks = response .json ()
132208 assert len (tasks ) > 0
133209 for task in tasks :
134210 qualities = {q ["name" ]: q ["value" ] for q in task ["quality" ]}
135211 if "NumberOfInstances" in qualities :
136212 assert min_instances <= float (qualities ["NumberOfInstances" ]) <= max_instances
137213
138214
139- async def test_list_tasks_inputs_are_basic_subset (py_api : httpx . AsyncClient ) -> None :
215+ async def test_list_tasks_inputs_are_basic_subset (expdb_test : AsyncConnection ) -> None :
140216 """Input entries only contain the expected basic input names."""
141217 basic_inputs = {"source_data" , "target_feature" , "estimation_procedure" , "evaluation_measures" }
142- response = await py_api .post ("/tasks/list" , json = {"pagination" : {"limit" : 5 , "offset" : 0 }})
143- assert response .status_code == HTTPStatus .OK
144- for task in response .json ():
218+ tasks = await list_tasks (pagination = Pagination (limit = 5 , offset = 0 ), expdb = expdb_test )
219+ for task in tasks :
145220 for inp in task ["input" ]:
146221 assert inp ["name" ] in basic_inputs
147222
148223
149- async def test_list_tasks_quality_values_are_strings (py_api : httpx . AsyncClient ) -> None :
224+ async def test_list_tasks_quality_values_are_strings (expdb_test : AsyncConnection ) -> None :
150225 """Quality values must be strings (to match PHP API behaviour)."""
151- response = await py_api .post ("/tasks/list" , json = {"pagination" : {"limit" : 5 , "offset" : 0 }})
152- assert response .status_code == HTTPStatus .OK
153- for task in response .json ():
226+ tasks = await list_tasks (pagination = Pagination (limit = 5 , offset = 0 ), expdb = expdb_test )
227+ for task in tasks :
154228 for quality in task ["quality" ]:
155229 assert isinstance (quality ["value" ], str )
156230
157231
158- @pytest .mark .parametrize (
159- "pagination_override" ,
160- [
161- {"limit" : "abc" , "offset" : 0 }, # Invalid type
162- {"limit" : 5 , "offset" : "xyz" }, # Invalid type
163- ],
164- ids = ["bad_limit_type" , "bad_offset_type" ],
165- )
166- async def test_list_tasks_invalid_pagination_type (
167- pagination_override : dict [str , Any ], py_api : httpx .AsyncClient
168- ) -> None :
169- """Invalid pagination types return 422 Unprocessable Entity."""
170- response = await py_api .post (
171- "/tasks/list" ,
172- json = {"pagination" : pagination_override },
173- )
174- assert response .status_code == HTTPStatus .UNPROCESSABLE_ENTITY
175-
176-
177- @pytest .mark .parametrize (
178- ("limit" , "offset" , "expected_status" , "expected_max_results" ),
179- [
180- (- 10 , 0 , HTTPStatus .NOT_FOUND , 0 ), # negative limit clamped to 0 -> No results
181- (5 , - 10 , HTTPStatus .OK , 5 ), # negative offset clamped to 0 -> First 5 results
182- ],
183- ids = ["negative_limit" , "negative_offset" ],
184- )
185- async def test_list_tasks_negative_pagination_safely_clamped (
186- limit : int ,
187- offset : int ,
188- expected_status : int ,
189- expected_max_results : int ,
190- py_api : httpx .AsyncClient ,
191- ) -> None :
192- """Negative pagination values are safely clamped to 0 instead of causing 500 errors.
193-
194- A limit clamped to 0 returns a 482 NoResultsError (404 Not Found).
195- An offset clamped to 0 simply returns the first page of results (200 OK).
196- """
197- response = await py_api .post (
198- "/tasks/list" ,
199- json = {"pagination" : {"limit" : limit , "offset" : offset }},
200- )
201- assert response .status_code == expected_status
202- if expected_status == HTTPStatus .OK :
203- assert len (response .json ()) <= expected_max_results
204- else :
205- error = response .json ()
206- assert error ["type" ] == NoResultsError .uri
207-
208-
209232@pytest .mark .parametrize (
210233 "payload" ,
211234 [
@@ -215,21 +238,7 @@ async def test_list_tasks_negative_pagination_safely_clamped(
215238 ],
216239 ids = ["bad_tag" , "bad_task_id" , "bad_data_name" ],
217240)
218- async def test_list_tasks_no_results (payload : dict [str , Any ], py_api : httpx . AsyncClient ) -> None :
241+ async def test_list_tasks_no_results (payload : dict [str , Any ], expdb_test : AsyncConnection ) -> None :
219242 """Filters matching nothing return 404 NoResultsError."""
220- response = await py_api .post ("/tasks/list" , json = payload )
221- assert response .status_code == HTTPStatus .NOT_FOUND
222- assert response .headers ["content-type" ] == "application/problem+json"
223- error = response .json ()
224- assert error ["type" ] == NoResultsError .uri
225-
226-
227- @pytest .mark .parametrize (
228- "value" ,
229- ["1...2" , "abc" ],
230- ids = ["triple_dot" , "non_numeric" ],
231- )
232- async def test_list_tasks_invalid_range (value : str , py_api : httpx .AsyncClient ) -> None :
233- """Invalid number_instances format returns 422 Unprocessable Entity."""
234- response = await py_api .post ("/tasks/list" , json = {"number_instances" : value })
235- assert response .status_code == HTTPStatus .UNPROCESSABLE_ENTITY
243+ with pytest .raises (NoResultsError ):
244+ await list_tasks (pagination = Pagination (), expdb = expdb_test , ** payload )
0 commit comments