Files
k7/tests/unit/test_client.py
T
G 76a8139774 Release 0.3.0
HTTPS-by-default for k7-api, cluster-wide Cilium isolation, first-class
--docker on Kata and k7d, and RuntimeClass k7-fc. Playbook pins k7d 0.6.0.
2026-09-13 17:30:37 +02:00

210 lines
7.8 KiB
Python

"""Unit tests for the k7_sdk client (sync + async).
These tests verify URL/body construction and response unwrapping for the
pause/resume/fork SDK additions. The HTTP layer is mocked so
the tests run without a live API.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from k7_sdk.client import AsyncClient, Client, SandboxProxy
def test_sync_client_accepts_ca_path(tmp_path):
ca = tmp_path / "ca.crt"
ca.write_text("dummy")
c = Client(endpoint="https://api.test", api_key="k", verify_ssl=str(ca))
assert c.session.verify == str(ca)
def test_sync_client_refuses_verify_false_on_https():
with pytest.raises(ValueError, match="not allowed for https"):
Client(endpoint="https://api.test", api_key="k", verify_ssl=False)
def test_sync_client_allows_verify_false_on_http():
c = Client(endpoint="http://api.test", api_key="k", verify_ssl=False)
assert c.session.verify is False
def _mock_response(payload: dict, status_code: int = 200) -> MagicMock:
r = MagicMock()
r.status_code = status_code
r.json.return_value = payload
r.raise_for_status.return_value = None
return r
# --- Client (sync) -----------------------------------------------------------
class TestSyncClientPause:
def test_pause_default_args(self):
c = Client(endpoint="http://api.test", api_key="k")
with patch.object(
c.session,
"post",
return_value=_mock_response({"data": {"message": "ok"}}),
) as post:
out = c.pause("demo")
assert out == {"message": "ok"}
post.assert_called_once()
url, kwargs = post.call_args[0][0], post.call_args[1]
assert url == "http://api.test/api/v1/sandboxes/demo/pause"
assert kwargs["json"] == {"namespace": "default"}
def test_pause_with_snapshot(self):
c = Client(endpoint="http://api.test", api_key="k")
with patch.object(
c.session,
"post",
return_value=_mock_response({"data": {"message": "ok"}}),
) as post:
c.pause("demo", namespace="ns", snapshot="snap1")
body = post.call_args[1]["json"]
assert body == {"namespace": "ns", "snapshot": "snap1"}
def test_pause_does_not_accept_legacy_kwargs(self):
"""pvc and snapshot_class were removed from the SDK surface."""
import inspect
sig = inspect.signature(Client.pause)
assert "pvc" not in sig.parameters
assert "snapshot_class" not in sig.parameters
class TestSyncClientResume:
def test_resume_posts_namespace(self):
c = Client(endpoint="http://api.test", api_key="k")
with patch.object(
c.session,
"post",
return_value=_mock_response({"data": {"message": "ok"}}),
) as post:
out = c.resume("demo", namespace="ns")
assert out == {"message": "ok"}
assert post.call_args[0][0] == "http://api.test/api/v1/sandboxes/demo/resume"
assert post.call_args[1]["json"] == {"namespace": "ns"}
class TestSyncClientFork:
def test_fork_returns_proxy(self):
c = Client(endpoint="http://api.test", api_key="k")
with patch.object(
c.session,
"post",
return_value=_mock_response({"data": {"name": "child", "source": "demo"}}),
) as post:
proxy = c.fork("demo", "child", namespace="ns", snapshot="snap1")
assert isinstance(proxy, SandboxProxy)
assert proxy.name == "child"
assert proxy.namespace == "ns"
assert proxy._client is c
assert post.call_args[0][0] == "http://api.test/api/v1/sandboxes/demo/fork"
assert post.call_args[1]["json"] == {
"new_name": "child",
"namespace": "ns",
"snapshot": "snap1",
}
def test_fork_without_snapshot(self):
c = Client(endpoint="http://api.test", api_key="k")
with patch.object(
c.session,
"post",
return_value=_mock_response({"data": {"name": "child"}}),
) as post:
c.fork("demo", "child")
body = post.call_args[1]["json"]
assert body == {"new_name": "child", "namespace": "default"}
assert "snapshot" not in body
# --- SandboxProxy ergonomics ------------------------------------------------
class TestSandboxProxyOps:
def test_pause_delegates_to_client(self):
c = Client(endpoint="http://api.test", api_key="k")
proxy = SandboxProxy("demo", "ns", c)
with patch.object(c, "pause", return_value={"message": "ok"}) as m:
proxy.pause(snapshot="snap1")
m.assert_called_once_with("demo", namespace="ns", snapshot="snap1")
def test_resume_delegates_to_client(self):
c = Client(endpoint="http://api.test", api_key="k")
proxy = SandboxProxy("demo", "ns", c)
with patch.object(c, "resume", return_value={"message": "ok"}) as m:
proxy.resume()
m.assert_called_once_with("demo", namespace="ns")
def test_fork_delegates_and_returns_proxy(self):
c = Client(endpoint="http://api.test", api_key="k")
proxy = SandboxProxy("demo", "ns", c)
returned_proxy = SandboxProxy("child", "ns", c)
with patch.object(c, "fork", return_value=returned_proxy) as m:
out = proxy.fork("child")
assert out is returned_proxy
m.assert_called_once_with("demo", "child", namespace="ns", snapshot=None)
# --- AsyncClient -------------------------------------------------------------
class TestAsyncClient:
async def test_pause_builds_body(self):
ac = AsyncClient(endpoint="http://api.test", api_key="k")
try:
post = AsyncMock(return_value=_mock_response({"data": {"message": "ok"}}))
with patch.object(ac._client, "post", post):
out = await ac.pause("demo", namespace="ns", snapshot="snap")
assert out == {"message": "ok"}
assert post.call_args[0][0] == "/api/v1/sandboxes/demo/pause"
assert post.call_args[1]["json"] == {"namespace": "ns", "snapshot": "snap"}
finally:
await ac.aclose()
async def test_resume(self):
ac = AsyncClient(endpoint="http://api.test", api_key="k")
try:
post = AsyncMock(return_value=_mock_response({"data": {"message": "ok"}}))
with patch.object(ac._client, "post", post):
out = await ac.resume("demo")
assert out == {"message": "ok"}
assert post.call_args[0][0] == "/api/v1/sandboxes/demo/resume"
assert post.call_args[1]["json"] == {"namespace": "default"}
finally:
await ac.aclose()
async def test_fork_returns_data(self):
ac = AsyncClient(endpoint="http://api.test", api_key="k")
try:
post = AsyncMock(return_value=_mock_response({"data": {"name": "child", "source": "demo"}}, 201))
with patch.object(ac._client, "post", post):
out = await ac.fork("demo", "child")
assert out["name"] == "child"
assert out["source"] == "demo"
body = post.call_args[1]["json"]
assert body == {"new_name": "child", "namespace": "default"}
finally:
await ac.aclose()
# --- Smoke: AsyncClient construction requires httpx --------------------------
def test_async_client_refuses_verify_false_on_https():
with pytest.raises(ValueError, match="not allowed for https"):
AsyncClient(endpoint="https://api.test", api_key="k", verify_ssl=False)
def test_async_client_requires_httpx(monkeypatch):
"""When httpx is unavailable, AsyncClient raises RuntimeError loudly."""
import k7_sdk.client as mod
monkeypatch.setattr(mod, "httpx", None)
with pytest.raises(RuntimeError, match="httpx is required"):
AsyncClient(endpoint="http://api.test", api_key="k")