feat(#20): worldtree-sdk adapter foundation — ratatoskr.wt (slice-1)
Slice-1 of the SDK cutover (docs/contracts/worldtree_sdk_cutover.contract.md): the adapter chokepoint onto worldtree-sdk 1.0.0, unit-tested but not yet wired to any surface (that is slice-2). - build_client(base_url, *, api_key, admin_key=None, transport) constructs the single WorldtreeClient over a ratatoskr-owned injected httpx.AsyncClient. INV-CUT-1: the SDK is given the transport (_owns_client=False) and never closes it — proven by a test asserting aclose() leaves ratatoskr's transport open. - translate_error implements the § Error map DEFAULT: SDK ApiError → the adapter's SessionApiFailed (carrying the SDK's parsed status/error_code/body); every discriminated WorldtreeError subclass passes through by identity. Route-specific rows land at their call-sites in later slices (the route is the discriminator). - SessionApiFailed gains error_code vs the retiring sessions.py copy (extends it per the contract error-map row); the two coexist transiently and reconcile in slice-2 (DEC-4 incremental cutover — nothing wires the adapter this slice, so they never meet at runtime). Deletes no hand-rolled path, so DEC-4's live-smoke bar does not apply yet. Suite 541 green (534 + 7 new); mypy + ruff clean. Patch (internal foundation; the cutover's minor bump is DEC-6 at slice-7 ship).
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""Unit tests for the worldtree-sdk adapter (`ratatoskr.wt`) — slice-1 foundation.
|
||||
|
||||
Covers the two foundation surfaces (issue #20 cutover contract, slice 1):
|
||||
* `build_client` — construction wiring + injected-transport ownership (INV-CUT-1:
|
||||
the SDK must never close ratatoskr's transport).
|
||||
* `translate_error` — the § Error map DEFAULT (`ApiError` → `SessionApiFailed`)
|
||||
plus discriminated-`WorldtreeError` passthrough (INV-CUT-2).
|
||||
|
||||
No ratatoskr surface (CLI / web / TUI) is exercised here — that wiring lands in
|
||||
slice 2. These tests hit no network (WorldtreeClient does no I/O at construction).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from worldtree_sdk import AgentNotAvailable, ApiError, WorldtreeClient
|
||||
|
||||
from ratatoskr.wt import SessionApiFailed, build_client, translate_error
|
||||
|
||||
|
||||
class TestBuildClient:
|
||||
async def test_constructs_worldtree_client(self) -> None:
|
||||
transport = httpx.AsyncClient()
|
||||
try:
|
||||
client = build_client(
|
||||
"https://wt.example:8081", api_key="ck-test", transport=transport
|
||||
)
|
||||
assert isinstance(client, WorldtreeClient)
|
||||
assert client.base_url == "https://wt.example:8081"
|
||||
finally:
|
||||
await transport.aclose()
|
||||
|
||||
async def test_injected_transport_is_ratatoskr_owned(self) -> None:
|
||||
# INV-CUT-1 [hard]: aclose() on the SDK client must NOT close ratatoskr's
|
||||
# transport — ratatoskr owns the lifecycle exactly as it does today.
|
||||
transport = httpx.AsyncClient()
|
||||
client = build_client("https://wt.example", api_key="ck", transport=transport)
|
||||
await client.aclose()
|
||||
assert client.closed is True
|
||||
assert transport.is_closed is False
|
||||
await transport.aclose()
|
||||
|
||||
async def test_admin_key_optional(self) -> None:
|
||||
transport = httpx.AsyncClient()
|
||||
try:
|
||||
# Absent admin_key → admin_auth=None; still constructs.
|
||||
without_admin = build_client(
|
||||
"https://wt.example", api_key="ck", transport=transport
|
||||
)
|
||||
assert isinstance(without_admin, WorldtreeClient)
|
||||
# Present admin_key → constructs (admin surface available in later slices).
|
||||
with_admin = build_client(
|
||||
"https://wt.example", api_key="ck", admin_key="ak", transport=transport
|
||||
)
|
||||
assert isinstance(with_admin, WorldtreeClient)
|
||||
finally:
|
||||
await transport.aclose()
|
||||
|
||||
|
||||
class TestTranslateError:
|
||||
def test_apierror_maps_to_session_api_failed_default(self) -> None:
|
||||
exc = ApiError("some_code", "boom", status=500, body="raw-body")
|
||||
mapped = translate_error(exc)
|
||||
assert isinstance(mapped, SessionApiFailed)
|
||||
assert mapped.status == 500
|
||||
assert mapped.error_code == "some_code"
|
||||
assert mapped.body == "raw-body"
|
||||
|
||||
def test_apierror_with_no_body_maps_cleanly(self) -> None:
|
||||
exc = ApiError("nope", "no body", status=404)
|
||||
mapped = translate_error(exc)
|
||||
assert isinstance(mapped, SessionApiFailed)
|
||||
assert mapped.status == 404
|
||||
assert mapped.error_code == "nope"
|
||||
assert mapped.body is None
|
||||
|
||||
def test_discriminated_subclass_passes_through_unchanged(self) -> None:
|
||||
# Discriminated WorldtreeError subclasses are already the right semantic
|
||||
# type — the adapter passes them through by identity (no re-wrap).
|
||||
exc = AgentNotAvailable("agent_not_available", "gone", status=409)
|
||||
assert translate_error(exc) is exc
|
||||
|
||||
def test_non_worldtree_error_passes_through_unchanged(self) -> None:
|
||||
exc = ValueError("unrelated")
|
||||
assert translate_error(exc) is exc
|
||||
Reference in New Issue
Block a user