e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
357 lines
14 KiB
Python
357 lines
14 KiB
Python
# Copyright 2025 Alibaba Group Holding Ltd.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""
|
|
Integration-style tests for Pool API routes (opensandbox_server/api/pool.py).
|
|
|
|
Routes are exercised via FastAPI TestClient. The K8s PoolService is patched
|
|
so no real cluster connection is needed.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
from fastapi.testclient import TestClient
|
|
from fastapi import HTTPException, status as http_status
|
|
|
|
from opensandbox_server.api.schema import (
|
|
CreatePoolRequest,
|
|
ListPoolsResponse,
|
|
PoolCapacitySpec,
|
|
PoolResponse,
|
|
PoolStatus,
|
|
UpdatePoolRequest,
|
|
)
|
|
from opensandbox_server.services.constants import SandboxErrorCodes
|
|
|
|
|
|
_POOL_SERVICE_PATCH = "opensandbox_server.api.pool._get_pool_service"
|
|
|
|
|
|
def _cap(buffer_max=3, buffer_min=1, pool_max=10, pool_min=0) -> PoolCapacitySpec:
|
|
return PoolCapacitySpec(
|
|
bufferMax=buffer_max,
|
|
bufferMin=buffer_min,
|
|
poolMax=pool_max,
|
|
poolMin=pool_min,
|
|
)
|
|
|
|
|
|
def _pool_response(
|
|
name: str = "test-pool",
|
|
buffer_max: int = 3,
|
|
pool_max: int = 10,
|
|
total: int = 2,
|
|
allocated: int = 1,
|
|
available: int = 1,
|
|
) -> PoolResponse:
|
|
return PoolResponse(
|
|
name=name,
|
|
capacitySpec=_cap(buffer_max=buffer_max, pool_max=pool_max),
|
|
status=PoolStatus(
|
|
total=total,
|
|
allocated=allocated,
|
|
available=available,
|
|
revision="rev-1",
|
|
),
|
|
)
|
|
|
|
|
|
def _create_request_body(name: str = "test-pool") -> dict:
|
|
return {
|
|
"name": name,
|
|
"template": {
|
|
"spec": {
|
|
"containers": [
|
|
{
|
|
"name": "sandbox",
|
|
"image": "python:3.11",
|
|
"command": ["tail", "-f", "/dev/null"],
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"capacitySpec": {
|
|
"bufferMax": 3,
|
|
"bufferMin": 1,
|
|
"poolMax": 10,
|
|
"poolMin": 0,
|
|
},
|
|
}
|
|
|
|
|
|
class TestPoolAuthentication:
|
|
def test_list_pools_without_api_key_returns_401(self, client: TestClient):
|
|
response = client.get("/pools")
|
|
assert response.status_code == 401
|
|
|
|
def test_pool_routes_exist_on_v1_prefix(self, client: TestClient, auth_headers: dict):
|
|
"""Verify the /v1/pools routes are registered (even if they return 501 on docker runtime)."""
|
|
with patch(_POOL_SERVICE_PATCH) as mock_svc_factory:
|
|
mock_svc_factory.side_effect = HTTPException(
|
|
status_code=http_status.HTTP_501_NOT_IMPLEMENTED,
|
|
detail={"code": "X", "message": "y"},
|
|
)
|
|
response = client.get("/v1/pools", headers=auth_headers)
|
|
assert response.status_code == 501
|
|
|
|
|
|
class TestPoolNotSupportedOnDockerRuntime:
|
|
"""Pool endpoints return 501 when PoolService raises 501 (non-k8s runtime)."""
|
|
|
|
def test_list_pools_returns_501(self, client: TestClient, auth_headers: dict):
|
|
with patch(_POOL_SERVICE_PATCH, side_effect=HTTPException(
|
|
status_code=501,
|
|
detail={"code": SandboxErrorCodes.K8S_POOL_NOT_SUPPORTED, "message": "not k8s"},
|
|
)):
|
|
response = client.get("/pools", headers=auth_headers)
|
|
assert response.status_code == 501
|
|
assert SandboxErrorCodes.K8S_POOL_NOT_SUPPORTED in response.json()["code"]
|
|
|
|
def test_create_pool_returns_501(self, client: TestClient, auth_headers: dict):
|
|
with patch(_POOL_SERVICE_PATCH, side_effect=HTTPException(
|
|
status_code=501,
|
|
detail={"code": SandboxErrorCodes.K8S_POOL_NOT_SUPPORTED, "message": "not k8s"},
|
|
)):
|
|
response = client.post("/pools", json=_create_request_body(), headers=auth_headers)
|
|
assert response.status_code == 501
|
|
|
|
|
|
class TestCreatePoolRoute:
|
|
def test_create_pool_success_returns_201(self, client: TestClient, auth_headers: dict):
|
|
mock_svc = MagicMock()
|
|
mock_svc.create_pool.return_value = _pool_response(name="new-pool")
|
|
|
|
with patch(_POOL_SERVICE_PATCH, return_value=mock_svc):
|
|
response = client.post("/pools", json=_create_request_body("new-pool"), headers=auth_headers)
|
|
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert body["name"] == "new-pool"
|
|
assert body["capacitySpec"]["bufferMax"] == 3
|
|
assert body["status"]["total"] == 2
|
|
|
|
def test_create_pool_missing_name_returns_422(self, client: TestClient, auth_headers: dict):
|
|
body = _create_request_body()
|
|
del body["name"]
|
|
with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()):
|
|
response = client.post("/pools", json=body, headers=auth_headers)
|
|
assert response.status_code == 422
|
|
|
|
def test_create_pool_missing_template_returns_422(self, client: TestClient, auth_headers: dict):
|
|
body = _create_request_body()
|
|
del body["template"]
|
|
with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()):
|
|
response = client.post("/pools", json=body, headers=auth_headers)
|
|
assert response.status_code == 422
|
|
|
|
def test_create_pool_missing_capacity_spec_returns_422(self, client: TestClient, auth_headers: dict):
|
|
body = _create_request_body()
|
|
del body["capacitySpec"]
|
|
with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()):
|
|
response = client.post("/pools", json=body, headers=auth_headers)
|
|
assert response.status_code == 422
|
|
|
|
def test_create_pool_invalid_name_pattern_returns_422(self, client: TestClient, auth_headers: dict):
|
|
"""Pool name must be a valid k8s name (no uppercase, no spaces)."""
|
|
body = _create_request_body("Invalid_Name")
|
|
with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()):
|
|
response = client.post("/pools", json=body, headers=auth_headers)
|
|
assert response.status_code == 422
|
|
|
|
def test_create_pool_negative_buffer_max_returns_422(self, client: TestClient, auth_headers: dict):
|
|
body = _create_request_body()
|
|
body["capacitySpec"]["bufferMax"] = -1
|
|
with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()):
|
|
response = client.post("/pools", json=body, headers=auth_headers)
|
|
assert response.status_code == 422
|
|
|
|
def test_create_pool_duplicate_returns_409(self, client: TestClient, auth_headers: dict):
|
|
mock_svc = MagicMock()
|
|
mock_svc.create_pool.side_effect = HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"code": SandboxErrorCodes.K8S_POOL_ALREADY_EXISTS,
|
|
"message": "Pool 'dup-pool' already exists.",
|
|
},
|
|
)
|
|
with patch(_POOL_SERVICE_PATCH, return_value=mock_svc):
|
|
response = client.post("/pools", json=_create_request_body("dup-pool"), headers=auth_headers)
|
|
|
|
assert response.status_code == 409
|
|
assert SandboxErrorCodes.K8S_POOL_ALREADY_EXISTS in response.json()["code"]
|
|
|
|
def test_create_pool_passes_request_to_service(self, client: TestClient, auth_headers: dict):
|
|
mock_svc = MagicMock()
|
|
mock_svc.create_pool.return_value = _pool_response()
|
|
|
|
with patch(_POOL_SERVICE_PATCH, return_value=mock_svc):
|
|
client.post("/pools", json=_create_request_body("my-pool"), headers=auth_headers)
|
|
|
|
call_args = mock_svc.create_pool.call_args
|
|
req: CreatePoolRequest = call_args.args[0]
|
|
assert req.name == "my-pool"
|
|
assert req.capacity_spec.buffer_max == 3
|
|
assert req.capacity_spec.pool_max == 10
|
|
|
|
|
|
class TestListPoolsRoute:
|
|
def test_list_pools_returns_200_and_items(self, client: TestClient, auth_headers: dict):
|
|
mock_svc = MagicMock()
|
|
mock_svc.list_pools.return_value = ListPoolsResponse(
|
|
items=[_pool_response("pool-a"), _pool_response("pool-b")]
|
|
)
|
|
|
|
with patch(_POOL_SERVICE_PATCH, return_value=mock_svc):
|
|
response = client.get("/pools", headers=auth_headers)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert len(body["items"]) == 2
|
|
names = {p["name"] for p in body["items"]}
|
|
assert names == {"pool-a", "pool-b"}
|
|
|
|
def test_list_pools_response_has_status_fields(self, client: TestClient, auth_headers: dict):
|
|
mock_svc = MagicMock()
|
|
mock_svc.list_pools.return_value = ListPoolsResponse(
|
|
items=[_pool_response("p", total=5, allocated=3, available=2)]
|
|
)
|
|
with patch(_POOL_SERVICE_PATCH, return_value=mock_svc):
|
|
response = client.get("/pools", headers=auth_headers)
|
|
|
|
pool = response.json()["items"][0]
|
|
assert pool["status"]["total"] == 5
|
|
assert pool["status"]["allocated"] == 3
|
|
assert pool["status"]["available"] == 2
|
|
|
|
|
|
class TestGetPoolRoute:
|
|
def test_get_pool_success_returns_200(self, client: TestClient, auth_headers: dict):
|
|
mock_svc = MagicMock()
|
|
mock_svc.get_pool.return_value = _pool_response(name="my-pool")
|
|
|
|
with patch(_POOL_SERVICE_PATCH, return_value=mock_svc):
|
|
response = client.get("/pools/my-pool", headers=auth_headers)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["name"] == "my-pool"
|
|
|
|
def test_get_pool_not_found_returns_404(self, client: TestClient, auth_headers: dict):
|
|
mock_svc = MagicMock()
|
|
mock_svc.get_pool.side_effect = HTTPException(
|
|
status_code=404,
|
|
detail={
|
|
"code": SandboxErrorCodes.K8S_POOL_NOT_FOUND,
|
|
"message": "Pool 'ghost' not found.",
|
|
},
|
|
)
|
|
with patch(_POOL_SERVICE_PATCH, return_value=mock_svc):
|
|
response = client.get("/pools/ghost", headers=auth_headers)
|
|
|
|
assert response.status_code == 404
|
|
assert SandboxErrorCodes.K8S_POOL_NOT_FOUND in response.json()["code"]
|
|
|
|
|
|
class TestUpdatePoolRoute:
|
|
def _update_body(self, buffer_max=5, pool_max=20) -> dict:
|
|
return {
|
|
"capacitySpec": {
|
|
"bufferMax": buffer_max,
|
|
"bufferMin": 1,
|
|
"poolMax": pool_max,
|
|
"poolMin": 0,
|
|
}
|
|
}
|
|
|
|
def test_update_pool_success_returns_200(self, client: TestClient, auth_headers: dict):
|
|
mock_svc = MagicMock()
|
|
mock_svc.update_pool.return_value = _pool_response(buffer_max=5, pool_max=20)
|
|
|
|
with patch(_POOL_SERVICE_PATCH, return_value=mock_svc):
|
|
response = client.put("/pools/my-pool", json=self._update_body(), headers=auth_headers)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["capacitySpec"]["bufferMax"] == 5
|
|
|
|
def test_update_pool_calls_service_with_name_and_request(
|
|
self, client: TestClient, auth_headers: dict
|
|
):
|
|
mock_svc = MagicMock()
|
|
mock_svc.update_pool.return_value = _pool_response()
|
|
|
|
with patch(_POOL_SERVICE_PATCH, return_value=mock_svc):
|
|
client.put("/pools/target", json=self._update_body(buffer_max=9), headers=auth_headers)
|
|
|
|
call_args = mock_svc.update_pool.call_args
|
|
assert call_args.args[0] == "target"
|
|
req: UpdatePoolRequest = call_args.args[1]
|
|
assert req.capacity_spec.buffer_max == 9
|
|
|
|
def test_update_pool_not_found_returns_404(self, client: TestClient, auth_headers: dict):
|
|
mock_svc = MagicMock()
|
|
mock_svc.update_pool.side_effect = HTTPException(
|
|
status_code=404,
|
|
detail={
|
|
"code": SandboxErrorCodes.K8S_POOL_NOT_FOUND,
|
|
"message": "Pool 'x' not found.",
|
|
},
|
|
)
|
|
with patch(_POOL_SERVICE_PATCH, return_value=mock_svc):
|
|
response = client.put("/pools/x", json=self._update_body(), headers=auth_headers)
|
|
|
|
assert response.status_code == 404
|
|
|
|
def test_update_pool_missing_capacity_spec_returns_422(
|
|
self, client: TestClient, auth_headers: dict
|
|
):
|
|
with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()):
|
|
response = client.put("/pools/p", json={}, headers=auth_headers)
|
|
assert response.status_code == 422
|
|
|
|
def test_update_pool_negative_pool_max_returns_422(
|
|
self, client: TestClient, auth_headers: dict
|
|
):
|
|
with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()):
|
|
response = client.put(
|
|
"/pools/p",
|
|
json={"capacitySpec": {"bufferMax": 1, "bufferMin": 0, "poolMax": -5, "poolMin": 0}},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
class TestDeletePoolRoute:
|
|
def test_delete_pool_success_returns_204(self, client: TestClient, auth_headers: dict):
|
|
mock_svc = MagicMock()
|
|
mock_svc.delete_pool.return_value = None
|
|
|
|
with patch(_POOL_SERVICE_PATCH, return_value=mock_svc):
|
|
response = client.delete("/pools/my-pool", headers=auth_headers)
|
|
|
|
assert response.status_code == 204
|
|
assert response.content == b""
|
|
|
|
def test_delete_pool_not_found_returns_404(self, client: TestClient, auth_headers: dict):
|
|
mock_svc = MagicMock()
|
|
mock_svc.delete_pool.side_effect = HTTPException(
|
|
status_code=404,
|
|
detail={
|
|
"code": SandboxErrorCodes.K8S_POOL_NOT_FOUND,
|
|
"message": "Pool 'gone' not found.",
|
|
},
|
|
)
|
|
with patch(_POOL_SERVICE_PATCH, return_value=mock_svc):
|
|
response = client.delete("/pools/gone", headers=auth_headers)
|
|
|
|
assert response.status_code == 404
|
|
assert SandboxErrorCodes.K8S_POOL_NOT_FOUND in response.json()["code"]
|