|
| 1 | +"""Live API smoke tests for API routes touched by the CLI. |
| 2 | +
|
| 3 | +These tests run against a real server and validate route contracts that CLI commands rely on. |
| 4 | +They are skipped by default unless TLAB_RUN_LIVE_SERVER_TESTS=1 is set. |
| 5 | +""" |
| 6 | + |
| 7 | +import os |
| 8 | +import time |
| 9 | +import uuid |
| 10 | + |
| 11 | +import httpx |
| 12 | +import pytest |
| 13 | + |
| 14 | + |
| 15 | +RUN_LIVE_TESTS = os.getenv("TLAB_RUN_LIVE_SERVER_TESTS") == "1" |
| 16 | +BASE_URL = os.getenv("TLAB_LIVE_SERVER_URL", "http://127.0.0.1:8338").rstrip("/") |
| 17 | +LOGIN_EMAIL = os.getenv("TLAB_LIVE_TEST_EMAIL", "admin@example.com") |
| 18 | +LOGIN_PASSWORD = os.getenv("TLAB_LIVE_TEST_PASSWORD", "admin123") |
| 19 | + |
| 20 | + |
| 21 | +pytestmark = pytest.mark.skipif(not RUN_LIVE_TESTS, reason="Set TLAB_RUN_LIVE_SERVER_TESTS=1 to run live tests") |
| 22 | + |
| 23 | + |
| 24 | +def _auth_headers(client: httpx.Client) -> dict[str, str]: |
| 25 | + login_response = client.post( |
| 26 | + f"{BASE_URL}/auth/jwt/login", |
| 27 | + data={"username": LOGIN_EMAIL, "password": LOGIN_PASSWORD}, |
| 28 | + headers={"Content-Type": "application/x-www-form-urlencoded"}, |
| 29 | + ) |
| 30 | + assert login_response.status_code == 200, login_response.text |
| 31 | + |
| 32 | + token = login_response.json()["access_token"] |
| 33 | + auth_headers = {"Authorization": f"Bearer {token}"} |
| 34 | + |
| 35 | + teams_response = client.get(f"{BASE_URL}/users/me/teams", headers=auth_headers) |
| 36 | + assert teams_response.status_code == 200, teams_response.text |
| 37 | + |
| 38 | + teams_payload = teams_response.json() |
| 39 | + teams_list: list[dict] |
| 40 | + if isinstance(teams_payload, list): |
| 41 | + teams_list = teams_payload |
| 42 | + elif isinstance(teams_payload, dict): |
| 43 | + if isinstance(teams_payload.get("teams"), list): |
| 44 | + teams_list = teams_payload["teams"] |
| 45 | + elif isinstance(teams_payload.get("data"), list): |
| 46 | + teams_list = teams_payload["data"] |
| 47 | + else: |
| 48 | + teams_list = [] |
| 49 | + else: |
| 50 | + teams_list = [] |
| 51 | + |
| 52 | + assert teams_list, f"Expected at least one team for the test user, got: {teams_payload!r}" |
| 53 | + return {**auth_headers, "X-Team-Id": str(teams_list[0]["id"])} |
| 54 | + |
| 55 | + |
| 56 | +def _first_experiment_id(client: httpx.Client, headers: dict[str, str]) -> str: |
| 57 | + experiments_response = client.get(f"{BASE_URL}/experiment/", headers=headers) |
| 58 | + assert experiments_response.status_code == 200, experiments_response.text |
| 59 | + |
| 60 | + payload = experiments_response.json() |
| 61 | + if isinstance(payload, list) and payload: |
| 62 | + first = payload[0] |
| 63 | + if isinstance(first, dict): |
| 64 | + experiment_id = first.get("id") |
| 65 | + assert experiment_id, f"Experiment object missing id: {first!r}" |
| 66 | + return str(experiment_id) |
| 67 | + return str(first) |
| 68 | + if isinstance(payload, dict): |
| 69 | + for key in ("experiments", "data"): |
| 70 | + value = payload.get(key) |
| 71 | + if isinstance(value, list) and value: |
| 72 | + first = value[0] |
| 73 | + if isinstance(first, dict): |
| 74 | + experiment_id = first.get("id") |
| 75 | + assert experiment_id, f"Experiment object missing id: {first!r}" |
| 76 | + return str(experiment_id) |
| 77 | + return str(first) |
| 78 | + raise AssertionError(f"Expected at least one experiment, got: {payload!r}") |
| 79 | + |
| 80 | + |
| 81 | +def _assert_status_in(response: httpx.Response, expected: set[int], route_name: str) -> None: |
| 82 | + assert response.status_code in expected, ( |
| 83 | + f"{route_name} returned unexpected status {response.status_code}. " |
| 84 | + f"Expected one of {sorted(expected)}. Body: {response.text}" |
| 85 | + ) |
| 86 | + |
| 87 | + |
| 88 | +def _assert_json_message(response: httpx.Response, accepted_messages: set[str], route_name: str) -> None: |
| 89 | + try: |
| 90 | + payload = response.json() |
| 91 | + except ValueError as exc: |
| 92 | + raise AssertionError(f"{route_name} expected JSON body for 200 response. Body: {response.text}") from exc |
| 93 | + message = str(payload.get("message", payload.get("detail", ""))).strip().lower() |
| 94 | + assert message in accepted_messages, ( |
| 95 | + f"{route_name} returned 200 with unexpected JSON message '{message}'. Body: {response.text}" |
| 96 | + ) |
| 97 | + |
| 98 | + |
| 99 | +def _assert_missing_contract(response: httpx.Response, route_name: str) -> None: |
| 100 | + """Accept 404 or legacy 200 JSON with missing-resource message.""" |
| 101 | + if response.status_code == 404: |
| 102 | + return |
| 103 | + if response.status_code == 200: |
| 104 | + _assert_json_message(response, {"not found"}, route_name) |
| 105 | + return |
| 106 | + raise AssertionError( |
| 107 | + f"{route_name} returned unexpected status {response.status_code}. " |
| 108 | + f"Expected 404 or legacy 200/NOT FOUND. Body: {response.text}" |
| 109 | + ) |
| 110 | + |
| 111 | + |
| 112 | +def _assert_stop_contract(response: httpx.Response, route_name: str) -> None: |
| 113 | + """Accept backend stop-job variants for missing/non-running jobs.""" |
| 114 | + if response.status_code == 404: |
| 115 | + return |
| 116 | + if response.status_code == 200: |
| 117 | + _assert_json_message(response, {"ok", "not found"}, route_name) |
| 118 | + return |
| 119 | + raise AssertionError( |
| 120 | + f"{route_name} returned unexpected status {response.status_code}. " |
| 121 | + f"Expected 404 or legacy 200/OK|NOT FOUND. Body: {response.text}" |
| 122 | + ) |
| 123 | + |
| 124 | + |
| 125 | +def _assert_stream_or_logs_missing_contract(response: httpx.Response, route_name: str) -> None: |
| 126 | + """Accept missing-job behavior for log/stream endpoints. |
| 127 | +
|
| 128 | + Some backends return 404, while others return 200 with plain text payloads |
| 129 | + like 'data: Error: ...' for missing jobs. |
| 130 | + """ |
| 131 | + if response.status_code == 404: |
| 132 | + return |
| 133 | + if response.status_code == 200: |
| 134 | + content = response.text.lower() |
| 135 | + assert any(token in content for token in ("error", "not found", "no log files found")), ( |
| 136 | + f"{route_name} returned 200 without a recognizable missing/error payload. Body: {response.text}" |
| 137 | + ) |
| 138 | + return |
| 139 | + raise AssertionError( |
| 140 | + f"{route_name} returned unexpected status {response.status_code}. " |
| 141 | + f"Expected 404 or legacy 200/error text. Body: {response.text}" |
| 142 | + ) |
| 143 | + |
| 144 | + |
| 145 | +def _get_with_retry( |
| 146 | + client: httpx.Client, |
| 147 | + url: str, |
| 148 | + headers: dict[str, str], |
| 149 | + attempts: int = 3, |
| 150 | + delay_seconds: float = 0.5, |
| 151 | +) -> httpx.Response: |
| 152 | + """Retry GET briefly for routes that may become consistent asynchronously.""" |
| 153 | + response = client.get(url, headers=headers) |
| 154 | + for _ in range(attempts - 1): |
| 155 | + if response.status_code != 404: |
| 156 | + break |
| 157 | + time.sleep(delay_seconds) |
| 158 | + response = client.get(url, headers=headers) |
| 159 | + return response |
| 160 | + |
| 161 | + |
| 162 | +@pytest.fixture() |
| 163 | +def live_context() -> dict[str, str]: |
| 164 | + with httpx.Client(timeout=30.0) as client: |
| 165 | + headers = _auth_headers(client) |
| 166 | + auth_only_headers = {"Authorization": headers["Authorization"]} |
| 167 | + experiment_id = _first_experiment_id(client, headers) |
| 168 | + return {"headers": headers, "auth_only_headers": auth_only_headers, "experiment_id": experiment_id} |
| 169 | + |
| 170 | + |
| 171 | +@pytest.fixture() |
| 172 | +def provider_id(live_context: dict[str, str]) -> str: |
| 173 | + created_provider_id: str | None = None |
| 174 | + with httpx.Client(timeout=30.0) as client: |
| 175 | + headers = live_context["headers"] |
| 176 | + create_payload = {"name": f"cli-route-smoke-{uuid.uuid4().hex[:8]}", "type": "local", "config": {}} |
| 177 | + create_response = client.post(f"{BASE_URL}/compute_provider/providers/", headers=headers, json=create_payload) |
| 178 | + _assert_status_in(create_response, {200}, "POST /compute_provider/providers/") |
| 179 | + created_provider_id = create_response.json().get("id") |
| 180 | + assert created_provider_id, "Provider create response did not include id" |
| 181 | + |
| 182 | + try: |
| 183 | + return created_provider_id |
| 184 | + finally: |
| 185 | + if created_provider_id: |
| 186 | + with httpx.Client(timeout=30.0) as cleanup_client: |
| 187 | + cleanup_client.delete( |
| 188 | + f"{BASE_URL}/compute_provider/providers/{created_provider_id}", |
| 189 | + headers=live_context["headers"], |
| 190 | + ) |
| 191 | + |
| 192 | + |
| 193 | +def test_cli_auth_and_experiment_routes_live_server(live_context: dict[str, str]) -> None: |
| 194 | + with httpx.Client(timeout=30.0) as client: |
| 195 | + _assert_status_in(client.get(f"{BASE_URL}/healthz"), {200}, "GET /healthz") |
| 196 | + _assert_status_in( |
| 197 | + client.get(f"{BASE_URL}/users/me", headers=live_context["auth_only_headers"]), |
| 198 | + {200}, |
| 199 | + "GET /users/me", |
| 200 | + ) |
| 201 | + _assert_status_in( |
| 202 | + client.get(f"{BASE_URL}/users/me/teams", headers=live_context["auth_only_headers"]), |
| 203 | + {200}, |
| 204 | + "GET /users/me/teams", |
| 205 | + ) |
| 206 | + _assert_status_in( |
| 207 | + client.get(f"{BASE_URL}/experiment/", headers=live_context["headers"]), |
| 208 | + {200}, |
| 209 | + "GET /experiment/", |
| 210 | + ) |
| 211 | + |
| 212 | + |
| 213 | +def test_cli_compute_provider_routes_live_server(live_context: dict[str, str], provider_id: str) -> None: |
| 214 | + headers = live_context["headers"] |
| 215 | + with httpx.Client(timeout=30.0) as client: |
| 216 | + _assert_status_in( |
| 217 | + client.get(f"{BASE_URL}/compute_provider/providers/?include_disabled=false", headers=headers), |
| 218 | + {200}, |
| 219 | + "GET /compute_provider/providers/", |
| 220 | + ) |
| 221 | + provider_info_response = _get_with_retry( |
| 222 | + client, |
| 223 | + f"{BASE_URL}/compute_provider/providers/{provider_id}", |
| 224 | + headers, |
| 225 | + ) |
| 226 | + _assert_status_in(provider_info_response, {200, 404}, "GET /compute_provider/providers/{id}") |
| 227 | + _assert_status_in( |
| 228 | + client.get(f"{BASE_URL}/compute_provider/providers/{provider_id}/check", headers=headers), |
| 229 | + {200, 400, 404, 422}, |
| 230 | + "GET /compute_provider/providers/{id}/check", |
| 231 | + ) |
| 232 | + _assert_status_in( |
| 233 | + client.patch( |
| 234 | + f"{BASE_URL}/compute_provider/providers/{provider_id}", headers=headers, json={"disabled": True} |
| 235 | + ), |
| 236 | + {200, 404}, |
| 237 | + "PATCH /compute_provider/providers/{id} disable", |
| 238 | + ) |
| 239 | + _assert_status_in( |
| 240 | + client.patch( |
| 241 | + f"{BASE_URL}/compute_provider/providers/{provider_id}", headers=headers, json={"disabled": False} |
| 242 | + ), |
| 243 | + {200, 404}, |
| 244 | + "PATCH /compute_provider/providers/{id} enable", |
| 245 | + ) |
| 246 | + _assert_status_in( |
| 247 | + client.post(f"{BASE_URL}/compute_provider/providers/{provider_id}/launch/", headers=headers, json={}), |
| 248 | + {200, 202, 400, 404, 422}, |
| 249 | + "POST /compute_provider/providers/{id}/launch/", |
| 250 | + ) |
| 251 | + |
| 252 | + |
| 253 | +def test_cli_task_routes_live_server(live_context: dict[str, str]) -> None: |
| 254 | + headers = live_context["headers"] |
| 255 | + experiment_id = live_context["experiment_id"] |
| 256 | + fake_task_id = "route-smoke-task-id" |
| 257 | + |
| 258 | + with httpx.Client(timeout=30.0) as client: |
| 259 | + _assert_status_in( |
| 260 | + client.get( |
| 261 | + f"{BASE_URL}/experiment/{experiment_id}/task/list_by_type_in_experiment?type=REMOTE", headers=headers |
| 262 | + ), |
| 263 | + {200}, |
| 264 | + "GET /experiment/{id}/task/list_by_type_in_experiment", |
| 265 | + ) |
| 266 | + _assert_status_in( |
| 267 | + client.get(f"{BASE_URL}/experiment/{experiment_id}/task/gallery", headers=headers), |
| 268 | + {200}, |
| 269 | + "GET /experiment/{id}/task/gallery", |
| 270 | + ) |
| 271 | + _assert_status_in( |
| 272 | + client.get(f"{BASE_URL}/experiment/{experiment_id}/task/gallery/interactive", headers=headers), |
| 273 | + {200}, |
| 274 | + "GET /experiment/{id}/task/gallery/interactive", |
| 275 | + ) |
| 276 | + _assert_status_in( |
| 277 | + client.post( |
| 278 | + f"{BASE_URL}/experiment/{experiment_id}/task/validate", |
| 279 | + headers={**headers, "Content-Type": "text/plain"}, |
| 280 | + content="name: smoke\nrun: echo hi\ntype: trainer\n", |
| 281 | + ), |
| 282 | + {200, 400, 422}, |
| 283 | + "POST /experiment/{id}/task/validate", |
| 284 | + ) |
| 285 | + _assert_missing_contract( |
| 286 | + client.get(f"{BASE_URL}/experiment/{experiment_id}/task/{fake_task_id}/get", headers=headers), |
| 287 | + "GET /experiment/{id}/task/{task_id}/get", |
| 288 | + ) |
| 289 | + _assert_status_in( |
| 290 | + client.get(f"{BASE_URL}/experiment/{experiment_id}/task/{fake_task_id}/delete", headers=headers), |
| 291 | + {200, 404}, |
| 292 | + "GET /experiment/{id}/task/{task_id}/delete", |
| 293 | + ) |
| 294 | + _assert_status_in( |
| 295 | + client.post( |
| 296 | + f"{BASE_URL}/experiment/{experiment_id}/task/create", |
| 297 | + headers=headers, |
| 298 | + json={"github_repo_url": "https://github.com/does-not-exist/repo"}, |
| 299 | + ), |
| 300 | + {200, 400, 404, 422}, |
| 301 | + "POST /experiment/{id}/task/create (json)", |
| 302 | + ) |
| 303 | + _assert_status_in( |
| 304 | + client.post( |
| 305 | + f"{BASE_URL}/experiment/{experiment_id}/task/gallery/import", |
| 306 | + headers=headers, |
| 307 | + json={"gallery_id": "route-smoke-gallery-id", "experiment_id": experiment_id, "is_interactive": False}, |
| 308 | + ), |
| 309 | + {200, 400, 404, 422}, |
| 310 | + "POST /experiment/{id}/task/gallery/import", |
| 311 | + ) |
| 312 | + |
| 313 | + |
| 314 | +def test_cli_job_and_artifact_routes_live_server(live_context: dict[str, str]) -> None: |
| 315 | + headers = live_context["headers"] |
| 316 | + experiment_id = live_context["experiment_id"] |
| 317 | + fake_job_id = "route-smoke-job-id" |
| 318 | + |
| 319 | + with httpx.Client(timeout=30.0) as client: |
| 320 | + _assert_status_in( |
| 321 | + client.get(f"{BASE_URL}/experiment/{experiment_id}/jobs/list?type=REMOTE", headers=headers), |
| 322 | + {200}, |
| 323 | + "GET /experiment/{id}/jobs/list", |
| 324 | + ) |
| 325 | + _assert_stop_contract( |
| 326 | + client.get(f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/stop", headers=headers), |
| 327 | + "GET /experiment/{id}/jobs/{job_id}/stop", |
| 328 | + ) |
| 329 | + _assert_stream_or_logs_missing_contract( |
| 330 | + client.get(f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/provider_logs", headers=headers), |
| 331 | + "GET /experiment/{id}/jobs/{job_id}/provider_logs", |
| 332 | + ) |
| 333 | + _assert_stream_or_logs_missing_contract( |
| 334 | + client.get(f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/stream_output", headers=headers), |
| 335 | + "GET /experiment/{id}/jobs/{job_id}/stream_output", |
| 336 | + ) |
| 337 | + _assert_stream_or_logs_missing_contract( |
| 338 | + client.get(f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/request_logs", headers=headers), |
| 339 | + "GET /experiment/{id}/jobs/{job_id}/request_logs", |
| 340 | + ) |
| 341 | + _assert_missing_contract( |
| 342 | + client.get(f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/tunnel_info", headers=headers), |
| 343 | + "GET /experiment/{id}/jobs/{job_id}/tunnel_info", |
| 344 | + ) |
| 345 | + _assert_status_in( |
| 346 | + client.get(f"{BASE_URL}/jobs/{fake_job_id}/artifacts", headers=headers), |
| 347 | + {200, 400, 404}, |
| 348 | + "GET /jobs/{job_id}/artifacts", |
| 349 | + ) |
| 350 | + _assert_status_in( |
| 351 | + client.get(f"{BASE_URL}/jobs/{fake_job_id}/artifact/does-not-exist.txt?task=download", headers=headers), |
| 352 | + {400, 404, 405}, |
| 353 | + "GET /jobs/{job_id}/artifact/{filename}", |
| 354 | + ) |
| 355 | + _assert_status_in( |
| 356 | + client.get(f"{BASE_URL}/jobs/{fake_job_id}/artifacts/download_all", headers=headers), |
| 357 | + {400, 404}, |
| 358 | + "GET /jobs/{job_id}/artifacts/download_all", |
| 359 | + ) |
| 360 | + _assert_status_in( |
| 361 | + client.post( |
| 362 | + f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/datasets/does-not-exist/save_to_registry" |
| 363 | + "?mode=new&tag=latest&version_label=v1", |
| 364 | + headers=headers, |
| 365 | + ), |
| 366 | + {400, 404, 422}, |
| 367 | + "POST /experiment/{id}/jobs/{job_id}/datasets/{name}/save_to_registry", |
| 368 | + ) |
| 369 | + _assert_status_in( |
| 370 | + client.post( |
| 371 | + f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/models/does-not-exist/save_to_registry" |
| 372 | + "?mode=new&tag=latest&version_label=v1", |
| 373 | + headers=headers, |
| 374 | + ), |
| 375 | + {400, 404, 422}, |
| 376 | + "POST /experiment/{id}/jobs/{job_id}/models/{name}/save_to_registry", |
| 377 | + ) |
0 commit comments