Files
2026-07-13 12:24:33 +08:00

255 lines
8.6 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Test cases for the /backends/* API endpoints.
Tests cover:
- GET /backends — list active backends
- DELETE /backends/{name} — close and remove a backend
- POST /backends — create new backends from current config
- POST /backends/{name}/recreate — atomic close + create
- End-to-end flow: close → conf update → create → verify
"""
# Standard
from collections import OrderedDict
from unittest.mock import MagicMock
# Third Party
from fastapi.testclient import TestClient
import pytest
# First Party
from lmcache.v1.internal_api_server.api_server import app
# ------------------------------------------------------------------ #
# Fixtures
# ------------------------------------------------------------------ #
class FakeBackend:
"""Minimal fake backend for testing."""
def __init__(self, name: str = "FakeBackend"):
self._name = name
self.closed = False
def close(self):
self.closed = True
def __str__(self):
return self._name
@pytest.fixture
def mock_storage_manager():
"""Create a mock StorageManager with two fake backends."""
sm = MagicMock()
fake_cpu = FakeBackend("LocalCPUBackend")
fake_remote = FakeBackend("RemoteBackend")
backends = OrderedDict(
[
("LocalCPUBackend", fake_cpu),
("RemoteBackend", fake_remote),
]
)
sm.storage_backends = backends
# Wire real-ish list_backends
sm.list_backends.side_effect = lambda: {
n: type(b).__name__ for n, b in sm.storage_backends.items()
}
return sm
@pytest.fixture
def mock_engine(mock_storage_manager):
engine = MagicMock()
engine.storage_manager = mock_storage_manager
return engine
@pytest.fixture
def client_with_engine(mock_engine):
adapter = MagicMock()
adapter.lmcache_engine = mock_engine
app.state.lmcache_adapter = adapter
client = TestClient(app)
yield client
client.close()
@pytest.fixture
def client_without_engine():
adapter = MagicMock()
adapter.lmcache_engine = None
app.state.lmcache_adapter = adapter
client = TestClient(app)
yield client
client.close()
# ------------------------------------------------------------------ #
# GET /backends
# ------------------------------------------------------------------ #
class TestListBackends:
def test_list_success(self, client_with_engine, mock_storage_manager):
resp = client_with_engine.get("/backends")
assert resp.status_code == 200
data = resp.json()
assert "LocalCPUBackend" in data
assert "RemoteBackend" in data
def test_list_no_engine(self, client_without_engine):
resp = client_without_engine.get("/backends")
assert resp.status_code == 503
def test_list_exception(self, client_with_engine, mock_storage_manager):
mock_storage_manager.list_backends.side_effect = RuntimeError("boom")
resp = client_with_engine.get("/backends")
assert resp.status_code == 500
data = resp.json()
assert "boom" in data["message"]
# ------------------------------------------------------------------ #
# DELETE /backends/{name}
# ------------------------------------------------------------------ #
class TestCloseBackend:
def test_close_success(self, client_with_engine, mock_storage_manager):
mock_storage_manager.close_backend.return_value = True
resp = client_with_engine.delete("/backends/RemoteBackend")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "success"
mock_storage_manager.close_backend.assert_called_once_with("RemoteBackend")
def test_close_not_found(self, client_with_engine, mock_storage_manager):
mock_storage_manager.close_backend.return_value = False
resp = client_with_engine.delete("/backends/NonExistent")
assert resp.status_code == 404
data = resp.json()
assert data["status"] == "not_found"
def test_close_no_engine(self, client_without_engine):
resp = client_without_engine.delete("/backends/RemoteBackend")
assert resp.status_code == 503
def test_close_exception(self, client_with_engine, mock_storage_manager):
mock_storage_manager.close_backend.side_effect = RuntimeError("close error")
resp = client_with_engine.delete("/backends/RemoteBackend")
assert resp.status_code == 500
data = resp.json()
assert "close error" in data["message"]
# ------------------------------------------------------------------ #
# POST /backends
# ------------------------------------------------------------------ #
class TestCreateBackends:
def test_create_success(self, client_with_engine, mock_storage_manager):
mock_storage_manager.create_backends.return_value = {"GdsBackend": "GdsBackend"}
resp = client_with_engine.post("/backends")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "success"
assert "GdsBackend" in data["created"]
def test_create_no_engine(self, client_without_engine):
resp = client_without_engine.post("/backends")
assert resp.status_code == 503
def test_create_exception(self, client_with_engine, mock_storage_manager):
mock_storage_manager.create_backends.side_effect = RuntimeError("create error")
resp = client_with_engine.post("/backends")
assert resp.status_code == 500
data = resp.json()
assert "create error" in data["message"]
# ------------------------------------------------------------------ #
# POST /backends/{name}/recreate
# ------------------------------------------------------------------ #
class TestRecreateBackend:
def test_recreate_success(self, client_with_engine, mock_storage_manager):
mock_storage_manager.recreate_backend.return_value = {
"RemoteBackend": "RemoteBackend"
}
resp = client_with_engine.post("/backends/RemoteBackend/recreate")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "success"
assert "RemoteBackend" in data["recreated"]
mock_storage_manager.recreate_backend.assert_called_once_with("RemoteBackend")
def test_recreate_not_found(self, client_with_engine, mock_storage_manager):
mock_storage_manager.recreate_backend.side_effect = KeyError(
"Backend NonExistent not found"
)
resp = client_with_engine.post("/backends/NonExistent/recreate")
assert resp.status_code == 404
data = resp.json()
assert data["status"] == "not_found"
def test_recreate_no_engine(self, client_without_engine):
resp = client_without_engine.post("/backends/RemoteBackend/recreate")
assert resp.status_code == 503
def test_recreate_exception(self, client_with_engine, mock_storage_manager):
mock_storage_manager.recreate_backend.side_effect = RuntimeError(
"recreate error"
)
resp = client_with_engine.post("/backends/RemoteBackend/recreate")
assert resp.status_code == 500
data = resp.json()
assert "recreate error" in data["message"]
# ------------------------------------------------------------------ #
# End-to-end flow: close → conf → create
# ------------------------------------------------------------------ #
class TestBackendSwitchFlow:
"""Simulate the full backend-switch workflow via API."""
def test_close_update_conf_create_flow(
self, client_with_engine, mock_storage_manager
):
"""
1. DELETE /backends/RemoteBackend
2. POST /conf to update remote_url
3. POST /backends to create new RemoteBackend
4. GET /backends to verify
"""
# Step 1: close RemoteBackend
mock_storage_manager.close_backend.return_value = True
resp = client_with_engine.delete("/backends/RemoteBackend")
assert resp.status_code == 200
# Step 2: update config via /conf (uses conf_api)
# We patch the config object on the adapter
mock_config = MagicMock()
client_with_engine.app.state.lmcache_adapter.config = mock_config
# Step 3: create new backends
mock_storage_manager.create_backends.return_value = {
"RemoteBackend": "RemoteBackend"
}
resp = client_with_engine.post("/backends")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "success"
assert "RemoteBackend" in data["created"]
# Step 4: list backends
resp = client_with_engine.get("/backends")
assert resp.status_code == 200