## What's New? - Bugfixes - Updated test suites Reviewed-on: #15 Co-authored-by: pptx704 <rafeed@omukk.dev> Co-committed-by: pptx704 <rafeed@omukk.dev>
This commit is contained in:
@ -13,9 +13,16 @@ from wrenn.exceptions import (
|
||||
WrennValidationError,
|
||||
)
|
||||
from wrenn.models import (
|
||||
Actor,
|
||||
Capsule,
|
||||
CapsuleMetrics,
|
||||
CapsuleStats,
|
||||
MetricPoint,
|
||||
Resource,
|
||||
SSEEvent,
|
||||
Status,
|
||||
Template,
|
||||
UsageResponse,
|
||||
)
|
||||
|
||||
BASE = "https://app.wrenn.dev/api"
|
||||
@ -103,6 +110,212 @@ class TestCapsules:
|
||||
client.capsules.ping("sb-1")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_stats(self, client):
|
||||
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||
200,
|
||||
json={
|
||||
"range": "6h",
|
||||
"current": {"running_count": 2, "vcpus_reserved": 4},
|
||||
"peaks": {"running_count": 9},
|
||||
"series": {"running": [1, 2, 2]},
|
||||
},
|
||||
)
|
||||
stats = client.capsules.stats(range="6h")
|
||||
assert "range=6h" in str(route.calls[0].request.url)
|
||||
assert stats.current and stats.current.running_count == 2
|
||||
assert stats.peaks and stats.peaks.running_count == 9
|
||||
|
||||
@respx.mock
|
||||
def test_usage_passes_date_params(self, client):
|
||||
import datetime as _dt
|
||||
|
||||
route = respx.get(f"{BASE}/v1/capsules/usage").respond(
|
||||
200, json={"from": "2026-06-01", "to": "2026-06-22", "points": []}
|
||||
)
|
||||
client.capsules.usage(from_=_dt.date(2026, 6, 1), to="2026-06-22")
|
||||
url = str(route.calls[0].request.url)
|
||||
assert "from=2026-06-01" in url
|
||||
assert "to=2026-06-22" in url
|
||||
|
||||
@respx.mock
|
||||
def test_metrics(self, client):
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
|
||||
200,
|
||||
json={
|
||||
"sandbox_id": "sb-1",
|
||||
"range": "10m",
|
||||
"points": [{"timestamp_unix": 1, "cpu_pct": 12.5, "mem_bytes": 4096}],
|
||||
},
|
||||
)
|
||||
m = client.capsules.metrics("sb-1")
|
||||
assert m.sandbox_id == "sb-1"
|
||||
assert m.points and m.points[0].cpu_pct == 12.5
|
||||
assert isinstance(m, CapsuleMetrics)
|
||||
assert isinstance(m.points[0], MetricPoint)
|
||||
|
||||
@respx.mock
|
||||
def test_stats_default_omits_range(self, client):
|
||||
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||
200,
|
||||
json={"range": "1h", "current": {}, "peaks": {}, "series": {}},
|
||||
)
|
||||
stats = client.capsules.stats()
|
||||
assert "range=" not in str(route.calls[0].request.url)
|
||||
assert isinstance(stats, CapsuleStats)
|
||||
|
||||
@respx.mock
|
||||
def test_usage_default_omits_params(self, client):
|
||||
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
|
||||
usage = client.capsules.usage()
|
||||
url = str(route.calls[0].request.url)
|
||||
assert "from=" not in url
|
||||
assert "to=" not in url
|
||||
assert isinstance(usage, UsageResponse)
|
||||
|
||||
@respx.mock
|
||||
def test_metrics_default_omits_range(self, client):
|
||||
route = respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
|
||||
200, json={"sandbox_id": "sb-1", "points": []}
|
||||
)
|
||||
client.capsules.metrics("sb-1")
|
||||
assert "range=" not in str(route.calls[0].request.url)
|
||||
|
||||
@respx.mock
|
||||
def test_metrics_not_found(self, client):
|
||||
respx.get(f"{BASE}/v1/capsules/nope/metrics").respond(
|
||||
404,
|
||||
json={"error": {"code": "not_found", "message": "capsule not found"}},
|
||||
)
|
||||
with pytest.raises(WrennNotFoundError):
|
||||
client.capsules.metrics("nope")
|
||||
|
||||
@respx.mock
|
||||
def test_stats_auth_error(self, client):
|
||||
respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||
401,
|
||||
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||
)
|
||||
with pytest.raises(WrennAuthenticationError):
|
||||
client.capsules.stats()
|
||||
|
||||
@respx.mock
|
||||
def test_usage_string_dates(self, client):
|
||||
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
|
||||
client.capsules.usage(from_="2026-01-01", to="2026-01-31")
|
||||
url = str(route.calls[0].request.url)
|
||||
assert "from=2026-01-01" in url
|
||||
assert "to=2026-01-31" in url
|
||||
|
||||
@respx.mock
|
||||
def test_usage_partial_dates(self, client):
|
||||
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
|
||||
client.capsules.usage(from_="2026-01-01")
|
||||
url = str(route.calls[0].request.url)
|
||||
assert "from=2026-01-01" in url
|
||||
assert "to=" not in url
|
||||
|
||||
|
||||
class TestEvents:
|
||||
@respx.mock
|
||||
def test_stream_parses_sse_frames(self, client):
|
||||
body = (
|
||||
": keepalive\n"
|
||||
"event: capsule.create\n"
|
||||
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
"event: capsule.destroy\n"
|
||||
'data: {"event":"capsule.destroy","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
)
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
events = list(client.events.stream())
|
||||
assert [e.event.value for e in events] == [
|
||||
"capsule.create",
|
||||
"capsule.destroy",
|
||||
]
|
||||
assert events[0].resource and events[0].resource.id == "sb-1"
|
||||
|
||||
@respx.mock
|
||||
def test_stream_multiline_data(self, client):
|
||||
body = (
|
||||
"event: capsule.create\n"
|
||||
'data: {"event":"capsule.create",\n'
|
||||
'data: "resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
)
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
events = list(client.events.stream())
|
||||
assert len(events) == 1
|
||||
assert events[0].resource.id == "sb-1"
|
||||
|
||||
@respx.mock
|
||||
def test_stream_skips_keepalive_only(self, client):
|
||||
body = ": keepalive\n\n: keepalive\n\n"
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
assert list(client.events.stream()) == []
|
||||
|
||||
@respx.mock
|
||||
def test_stream_ignores_incomplete_trailing_frame(self, client):
|
||||
body = (
|
||||
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
'data: {"event":"capsule.destroy"' # no closing brace, no blank line
|
||||
)
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
events = list(client.events.stream())
|
||||
assert len(events) == 1
|
||||
assert events[0].event.value == "capsule.create"
|
||||
|
||||
@respx.mock
|
||||
def test_stream_full_payload_round_trip(self, client):
|
||||
body = (
|
||||
'data: {"event":"capsule.create",'
|
||||
'"outcome":"success",'
|
||||
'"resource":{"id":"sb-1","type":"capsule"},'
|
||||
'"actor":{"type":"api_key","id":"key-1","name":"ci"},'
|
||||
'"metadata":{"reason":"manual"}}\n'
|
||||
"\n"
|
||||
)
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
events = list(client.events.stream())
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert isinstance(ev, SSEEvent)
|
||||
assert isinstance(ev.resource, Resource)
|
||||
assert isinstance(ev.actor, Actor)
|
||||
assert ev.actor.name == "ci"
|
||||
assert ev.metadata == {"reason": "manual"}
|
||||
|
||||
@respx.mock
|
||||
def test_stream_raises_on_4xx(self, client):
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
401,
|
||||
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||
)
|
||||
with pytest.raises(WrennAuthenticationError):
|
||||
list(client.events.stream())
|
||||
|
||||
|
||||
class TestSnapshots:
|
||||
@respx.mock
|
||||
@ -262,6 +475,156 @@ class TestAsyncClient:
|
||||
with pytest.raises(WrennNotFoundError):
|
||||
await async_client.capsules.get("nope")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_stats(self, async_client):
|
||||
async with async_client:
|
||||
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||
200,
|
||||
json={
|
||||
"range": "24h",
|
||||
"current": {"running_count": 1},
|
||||
"peaks": {"running_count": 5},
|
||||
"series": {"running": [1]},
|
||||
},
|
||||
)
|
||||
stats = await async_client.capsules.stats(range="24h")
|
||||
assert "range=24h" in str(route.calls[0].request.url)
|
||||
assert isinstance(stats, CapsuleStats)
|
||||
assert stats.current.running_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_usage(self, async_client):
|
||||
import datetime as _dt
|
||||
|
||||
async with async_client:
|
||||
route = respx.get(f"{BASE}/v1/capsules/usage").respond(
|
||||
200,
|
||||
json={
|
||||
"from": "2026-06-01",
|
||||
"to": "2026-06-22",
|
||||
"points": [
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"cpu_minutes": 1.5,
|
||||
"ram_mb_minutes": 200.0,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
usage = await async_client.capsules.usage(
|
||||
from_=_dt.date(2026, 6, 1), to="2026-06-22"
|
||||
)
|
||||
url = str(route.calls[0].request.url)
|
||||
assert "from=2026-06-01" in url
|
||||
assert "to=2026-06-22" in url
|
||||
assert isinstance(usage, UsageResponse)
|
||||
assert usage.points and usage.points[0].cpu_minutes == 1.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_metrics(self, async_client):
|
||||
async with async_client:
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
|
||||
200,
|
||||
json={
|
||||
"sandbox_id": "sb-1",
|
||||
"range": "2h",
|
||||
"points": [
|
||||
{"timestamp_unix": 1, "cpu_pct": 33.0, "mem_bytes": 1024}
|
||||
],
|
||||
},
|
||||
)
|
||||
m = await async_client.capsules.metrics("sb-1", range="2h")
|
||||
assert isinstance(m, CapsuleMetrics)
|
||||
assert m.points[0].cpu_pct == 33.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_metrics_not_found(self, async_client):
|
||||
async with async_client:
|
||||
respx.get(f"{BASE}/v1/capsules/nope/metrics").respond(
|
||||
404,
|
||||
json={"error": {"code": "not_found", "message": "not found"}},
|
||||
)
|
||||
with pytest.raises(WrennNotFoundError):
|
||||
await async_client.capsules.metrics("nope")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_events_stream(self, async_client):
|
||||
async with async_client:
|
||||
body = (
|
||||
": keepalive\n"
|
||||
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
'data: {"event":"capsule.destroy","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
)
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
events = [ev async for ev in async_client.events.stream()]
|
||||
assert [e.event.value for e in events] == [
|
||||
"capsule.create",
|
||||
"capsule.destroy",
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_events_raises_on_4xx(self, async_client):
|
||||
async with async_client:
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
401,
|
||||
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||
)
|
||||
with pytest.raises(WrennAuthenticationError):
|
||||
async for _ in async_client.events.stream():
|
||||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_events_multiline_data(self, async_client):
|
||||
async with async_client:
|
||||
body = (
|
||||
'data: {"event":"capsule.create",\n'
|
||||
'data: "resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
)
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
events = [ev async for ev in async_client.events.stream()]
|
||||
assert len(events) == 1
|
||||
assert events[0].resource.id == "sb-1"
|
||||
|
||||
|
||||
class TestPackageSurface:
|
||||
def test_version(self):
|
||||
import wrenn
|
||||
|
||||
assert wrenn.__version__ == "0.3.0"
|
||||
|
||||
def test_new_models_exported(self):
|
||||
import wrenn.models as m
|
||||
|
||||
for name in (
|
||||
"Actor",
|
||||
"CapsuleMetrics",
|
||||
"CapsuleStats",
|
||||
"MetricPoint",
|
||||
"Resource",
|
||||
"SSEEvent",
|
||||
"UsageResponse",
|
||||
):
|
||||
assert hasattr(m, name), name
|
||||
assert name in m.__all__
|
||||
|
||||
|
||||
class TestClientResolution:
|
||||
def test_default_base_url_strips_app_subdomain(self):
|
||||
|
||||
Reference in New Issue
Block a user