mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
7a06f5e8bc
* refactor(session): make ModelLane the provider boundary (#979) ## Summary This closes the model-lane ownership gap left by #832: `ChatSession` no longer stores raw provider/client handles. `ResolvedModelBinding` now carries the provider, client, model, capabilities, registry generation, and backend-auth configuration as one coherent snapshot. - Atomically rebind existing sessions after model-registry changes while pinning each in-flight send, fallback, judge, output guard, task agent, title, compaction, perception, and voice operation to its initiating principal and binding. - Fence UI publication, canonical trajectory folds, durable writes, streams, retries, child scopes, and judge work by generation. Stop can hand off to a successor without accepting late state; cancelled tools retain typed effect receipts, and concurrent approval batches resolve by exact cycle or call. - Make create, fork, open, close, and delete race-safe with hidden `creating` reservations, incarnation-aware state tails, and an ACL-rechecked transaction that clones checkpoint-bounded history, configuration, project/persona state, and attachment references. - Extend REST/OpenAPI and Python/TypeScript SDK contracts for create/fork inputs, routed-create metadata, live-workstream probes, targeted approvals, and structured cancellation results. - Update architecture, storage, authentication, judge, channel, console, API, and SDK documentation, including regenerated architecture diagrams and OpenAPI artifacts. ## Validation - SQLite suite: 11,188 passed, 9 skipped, 10 deselected - PostgreSQL suite: 11,195 passed, 2 skipped, 10 deselected - Live backend: 3 passed - SSE recovery: 6 passed; browser recovery harness passed all scenarios - Ruff: clean; 595 files correctly formatted - mypy: 243 source files clean - TypeScript: typecheck/build and 35 tests passed - OpenAPI artifacts fresh; all 14 changed diagrams reproduce byte-for-byte - `git diff --check` and Git LFS integrity clean Closes #979. * fix(deps): update nanoid for GHSA-2v37-7h3g-55p8 Refresh the transitive lock entry admitted by PostCSS so the TypeScript security gate no longer resolves the vulnerable custom-generator implementation. Validation: - npm ci - npm audit --audit-level=moderate: 0 vulnerabilities - TypeScript typecheck and build - TypeScript tests: 35 passed * fix(test): assert canonical model registry URLs Replace prefix checks with exact canonical base URL assertions so the tests do not model incomplete URL validation. Validation: tests/test_model_registry.py (185 passed); Ruff check/format; mypy.
745 lines
26 KiB
Python
745 lines
26 KiB
Python
"""Tests for turnstone.sdk.console — console client with mocked HTTP transport."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from turnstone.sdk._types import TurnstoneAPIError
|
|
from turnstone.sdk.console import AsyncTurnstoneConsole
|
|
|
|
|
|
def _json_response(data: dict, status: int = 200) -> httpx.Response:
|
|
return httpx.Response(status, json=data)
|
|
|
|
|
|
def _mock_transport(
|
|
responses: dict[str, httpx.Response] | None = None,
|
|
) -> httpx.MockTransport:
|
|
table = responses or {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
key = f"{request.method} {request.url.path}"
|
|
if key in table:
|
|
return table[key]
|
|
return httpx.Response(404, json={"error": "not found"})
|
|
|
|
return httpx.MockTransport(handler)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routing proxy — rewind / retry (#549)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_rewind_sends_turns_body():
|
|
"""``route_rewind`` forwards ``{"turns": N}`` through the proxy."""
|
|
captured_path: list[str] = []
|
|
captured_body: list[dict] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_path.append(request.url.path)
|
|
captured_body.append(json.loads(request.content) if request.content else {})
|
|
return _json_response({"status": "ok", "removed": 3})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
await client.route_rewind("ws1", turns=3)
|
|
assert captured_path[0] == "/v1/api/route/workstreams/ws1/rewind"
|
|
assert captured_body[0] == {"turns": 3}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_retry_posts_to_path_keyed_endpoint():
|
|
transport = _mock_transport(
|
|
{
|
|
"POST /v1/api/route/workstreams/ws1/retry": _json_response(
|
|
{"status": "ok", "retried": True}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.route_retry("ws1")
|
|
assert resp["status"] == "ok"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cluster overview
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_overview():
|
|
transport = _mock_transport(
|
|
{
|
|
"GET /v1/api/cluster/overview": _json_response(
|
|
{
|
|
"nodes": 2,
|
|
"workstreams": 5,
|
|
"states": {"running": 1, "idle": 4},
|
|
"aggregate": {"total_tokens": 1000, "total_tool_calls": 20},
|
|
"version_drift": False,
|
|
"versions": ["0.3.0"],
|
|
}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.overview()
|
|
assert resp.nodes == 2
|
|
assert resp.workstreams == 5
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_nodes():
|
|
transport = _mock_transport(
|
|
{
|
|
"GET /v1/api/cluster/nodes": _json_response(
|
|
{
|
|
"nodes": [
|
|
{
|
|
"node_id": "n1",
|
|
"server_url": "http://localhost:8080",
|
|
"ws_total": 3,
|
|
"ws_running": 1,
|
|
"ws_thinking": 0,
|
|
"ws_attention": 0,
|
|
"ws_idle": 2,
|
|
"ws_error": 0,
|
|
"total_tokens": 500,
|
|
"started": 1700000000.0,
|
|
"reachable": True,
|
|
"health": {"status": "ok"},
|
|
"version": "0.3.0",
|
|
}
|
|
],
|
|
"total": 1,
|
|
}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.nodes(sort="tokens", limit=50)
|
|
assert resp.total == 1
|
|
assert resp.nodes[0].node_id == "n1"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_workstreams():
|
|
transport = _mock_transport(
|
|
{
|
|
"GET /v1/api/cluster/workstreams": _json_response(
|
|
{
|
|
"workstreams": [
|
|
{
|
|
"id": "ws1",
|
|
"name": "test",
|
|
"state": "running",
|
|
"node": "n1",
|
|
}
|
|
],
|
|
"total": 1,
|
|
"page": 1,
|
|
"per_page": 50,
|
|
"pages": 1,
|
|
}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.workstreams(state="running", page=1)
|
|
assert resp.total == 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_node_detail():
|
|
transport = _mock_transport(
|
|
{
|
|
"GET /v1/api/cluster/node/n1": _json_response(
|
|
{
|
|
"node_id": "n1",
|
|
"server_url": "http://localhost:8080",
|
|
"health": {"status": "ok"},
|
|
"workstreams": [],
|
|
"aggregate": {"total_tokens": 0, "total_tool_calls": 0},
|
|
}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.node_detail("n1")
|
|
assert resp.node_id == "n1"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_create_workstream():
|
|
transport = _mock_transport(
|
|
{
|
|
"POST /v1/api/cluster/workstreams/new": _json_response(
|
|
{"status": "dispatched", "correlation_id": "abc123", "target_node": "n1"}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.create_workstream(node_id="n1", name="test")
|
|
assert resp.correlation_id == "abc123"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_login():
|
|
transport = _mock_transport(
|
|
{"POST /v1/api/auth/login": _json_response({"status": "ok", "role": "read"})}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.login("tok_test")
|
|
assert resp.role == "read"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Health
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_health():
|
|
transport = _mock_transport(
|
|
{
|
|
"GET /health": _json_response(
|
|
{
|
|
"status": "ok",
|
|
"service": "turnstone-console",
|
|
"nodes": 2,
|
|
"workstreams": 5,
|
|
"version_drift": False,
|
|
"versions": ["0.3.0"],
|
|
}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.health()
|
|
assert resp.status == "ok"
|
|
assert resp.nodes == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_node_not_found():
|
|
transport = _mock_transport(
|
|
{"GET /v1/api/cluster/node/bad": httpx.Response(404, json={"error": "Node not found"})}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
with pytest.raises(TurnstoneAPIError) as exc_info:
|
|
await client.node_detail("bad")
|
|
assert exc_info.value.status_code == 404
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_query_params_passed():
|
|
"""Verify query params are sent correctly for paginated endpoints."""
|
|
captured_url: list[str] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_url.append(str(request.url))
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"workstreams": [],
|
|
"total": 0,
|
|
"page": 2,
|
|
"per_page": 25,
|
|
"pages": 0,
|
|
},
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
await client.workstreams(state="running", page=2, per_page=25)
|
|
assert "state=running" in captured_url[0]
|
|
assert "page=2" in captured_url[0]
|
|
assert "per_page=25" in captured_url[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schedules
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_SCHEDULE_FIXTURE = {
|
|
"task_id": "t1",
|
|
"name": "nightly",
|
|
"description": "",
|
|
"schedule_type": "cron",
|
|
"cron_expr": "0 2 * * *",
|
|
"at_time": "",
|
|
"target_mode": "auto",
|
|
"model": "",
|
|
"initial_message": "Run nightly checks",
|
|
"auto_approve": False,
|
|
"auto_approve_tools": [],
|
|
"enabled": True,
|
|
"created_by": "u1",
|
|
"last_run": None,
|
|
"next_run": "2026-03-06T02:00:00Z",
|
|
"created": "2026-03-05T12:00:00Z",
|
|
"updated": "2026-03-05T12:00:00Z",
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_list_schedules():
|
|
transport = _mock_transport(
|
|
{"GET /v1/api/admin/schedules": _json_response({"schedules": [_SCHEDULE_FIXTURE]})}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.list_schedules()
|
|
assert len(resp.schedules) == 1
|
|
assert resp.schedules[0].task_id == "t1"
|
|
assert resp.schedules[0].name == "nightly"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_create_schedule():
|
|
captured_body: list[dict] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_body.append(json.loads(request.content))
|
|
return _json_response(_SCHEDULE_FIXTURE)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.create_schedule(
|
|
name="nightly",
|
|
schedule_type="cron",
|
|
initial_message="Run nightly checks",
|
|
cron_expr="0 2 * * *",
|
|
persona="researcher",
|
|
project_id="proj_1",
|
|
)
|
|
assert resp.task_id == "t1"
|
|
body = captured_body[0]
|
|
assert body["name"] == "nightly"
|
|
assert body["schedule_type"] == "cron"
|
|
assert body["cron_expr"] == "0 2 * * *"
|
|
assert body["initial_message"] == "Run nightly checks"
|
|
assert body["persona"] == "researcher"
|
|
assert body["project_id"] == "proj_1"
|
|
# Optional fields with defaults should not appear when not set
|
|
assert "description" not in body
|
|
assert "model" not in body
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_get_schedule():
|
|
transport = _mock_transport(
|
|
{"GET /v1/api/admin/schedules/t1": _json_response(_SCHEDULE_FIXTURE)}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.get_schedule("t1")
|
|
assert resp.task_id == "t1"
|
|
assert resp.schedule_type == "cron"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_update_schedule_partial():
|
|
"""Only explicitly-passed fields should appear in the request body."""
|
|
captured_body: list[dict] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_body.append(json.loads(request.content))
|
|
return _json_response({**_SCHEDULE_FIXTURE, "enabled": False})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.update_schedule("t1", enabled=False)
|
|
assert resp.enabled is False
|
|
body = captured_body[0]
|
|
assert body == {"enabled": False}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_delete_schedule():
|
|
transport = _mock_transport(
|
|
{"DELETE /v1/api/admin/schedules/t1": _json_response({"status": "ok"})}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.delete_schedule("t1")
|
|
assert resp.status == "ok"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_list_schedule_runs():
|
|
transport = _mock_transport(
|
|
{
|
|
"GET /v1/api/admin/schedules/t1/runs": _json_response(
|
|
{
|
|
"runs": [
|
|
{
|
|
"run_id": "r1",
|
|
"task_id": "t1",
|
|
"node_id": "n1",
|
|
"ws_id": "ws1",
|
|
"correlation_id": "c1",
|
|
"started": "2026-03-05T02:00:00Z",
|
|
"status": "dispatched",
|
|
"error": "",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.list_schedule_runs("t1", limit=10)
|
|
assert len(resp.runs) == 1
|
|
assert resp.runs[0].run_id == "r1"
|
|
assert resp.runs[0].status == "dispatched"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# create_workstream extended params
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_cluster_create_sdk_signatures_match_live_schema():
|
|
"""The console SDK must not advertise fields the handler silently ignores."""
|
|
import inspect
|
|
|
|
from turnstone.api.console_schemas import ConsoleCreateWsRequest
|
|
from turnstone.sdk.console import TurnstoneConsole
|
|
|
|
expected = set(ConsoleCreateWsRequest.model_fields)
|
|
async_params = set(inspect.signature(AsyncTurnstoneConsole.create_workstream).parameters)
|
|
sync_params = set(inspect.signature(TurnstoneConsole.create_workstream).parameters)
|
|
assert async_params - {"self"} == expected
|
|
assert sync_params - {"self"} == expected
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_create_workstream_contract_params():
|
|
"""Cluster create exposes every optional field accepted by its schema."""
|
|
captured_body: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_body.update(json.loads(request.content))
|
|
return _json_response(
|
|
{"status": "dispatched", "correlation_id": "abc", "target_node": "n1"}
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
await client.create_workstream(
|
|
node_id="n1",
|
|
name="ext",
|
|
project_id="project-42",
|
|
judge_model="judge-fast",
|
|
)
|
|
assert captured_body["node_id"] == "n1"
|
|
assert captured_body["name"] == "ext"
|
|
assert captured_body["project_id"] == "project-42"
|
|
assert captured_body["judge_model"] == "judge-fast"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_create_workstream_omits_empty_new_params():
|
|
"""Default-valued new params should not appear in JSON body."""
|
|
captured_body: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_body.update(json.loads(request.content))
|
|
return _json_response(
|
|
{"status": "dispatched", "correlation_id": "abc", "target_node": "n1"}
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
await client.create_workstream(name="min")
|
|
assert captured_body == {"name": "min"}
|
|
assert "project_id" not in captured_body
|
|
assert "judge_model" not in captured_body
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Route methods
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_create_workstream():
|
|
captured_body: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_body.update(json.loads(request.content))
|
|
return _json_response(
|
|
{
|
|
"ws_id": "ws1",
|
|
"name": "routed",
|
|
"node_url": "http://n1:8080",
|
|
"node_id": "n1",
|
|
"routing_strategy": "target_node",
|
|
}
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.route_create_workstream(
|
|
name="routed",
|
|
model="gpt-5",
|
|
auto_approve=True,
|
|
project_id="project-42",
|
|
judge_model="judge-fast",
|
|
target_node="n1",
|
|
user_id="u1",
|
|
client_type="scheduled",
|
|
notify_targets=[{"channel_type": "slack", "channel_id": "C123"}],
|
|
)
|
|
assert resp.ws_id == "ws1"
|
|
assert resp.node_url == "http://n1:8080"
|
|
assert resp.routing_strategy == "target_node"
|
|
assert captured_body["name"] == "routed"
|
|
assert captured_body["model"] == "gpt-5"
|
|
assert captured_body["auto_approve"] is True
|
|
assert captured_body["project_id"] == "project-42"
|
|
assert captured_body["judge_model"] == "judge-fast"
|
|
assert captured_body["target_node"] == "n1"
|
|
assert captured_body["user_id"] == "u1"
|
|
assert captured_body["client_type"] == "scheduled"
|
|
assert captured_body["notify_targets"] == [{"channel_type": "slack", "channel_id": "C123"}]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_create_workstream_rejects_attachments_with_target_node():
|
|
"""Regression: target_node has no effect on multipart route_create
|
|
(which routes by ?ws_id=) — refuse the combination at the SDK boundary
|
|
instead of silently routing to the wrong node.
|
|
"""
|
|
from turnstone.sdk._types import AttachmentUpload
|
|
|
|
transport = httpx.MockTransport(
|
|
lambda req: _json_response({"error": "should not be called"}, status=500)
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
with pytest.raises(ValueError, match="target_node"):
|
|
await client.route_create_workstream(
|
|
name="x",
|
|
target_node="n1",
|
|
attachments=[AttachmentUpload(filename="a.txt", data=b"hi")],
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_create_workstream_omits_defaults():
|
|
captured_body: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_body.update(json.loads(request.content))
|
|
return _json_response(
|
|
{
|
|
"ws_id": "ws1",
|
|
"name": "bare",
|
|
"node_url": "http://n1:8080",
|
|
"node_id": "n1",
|
|
"routing_strategy": "rendezvous",
|
|
}
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
await client.route_create_workstream(name="bare")
|
|
assert captured_body == {"name": "bare"}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_send():
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["path"] = request.url.path
|
|
captured["body"] = json.loads(request.content)
|
|
return _json_response({"status": "ok"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.route_send("Hello", "ws1")
|
|
assert resp["status"] == "ok"
|
|
assert captured["path"] == "/v1/api/route/workstreams/ws1/send"
|
|
assert captured["body"] == {"message": "Hello"}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_approve():
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["path"] = request.url.path
|
|
captured["body"] = json.loads(request.content)
|
|
return _json_response({"status": "ok"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
await client.route_approve(ws_id="ws1", approved=False, feedback="no", always=True)
|
|
assert captured["path"] == "/v1/api/route/workstreams/ws1/approve"
|
|
assert captured["body"] == {
|
|
"approved": False,
|
|
"feedback": "no",
|
|
"always": True,
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_approve_omits_defaults():
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["path"] = request.url.path
|
|
captured["body"] = json.loads(request.content)
|
|
return _json_response({"status": "ok"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
await client.route_approve(ws_id="ws1", approved=True)
|
|
assert captured["path"] == "/v1/api/route/workstreams/ws1/approve"
|
|
assert captured["body"] == {"approved": True}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_close():
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["path"] = request.url.path
|
|
captured["body"] = json.loads(request.content)
|
|
return _json_response({"status": "ok"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.route_close("ws1")
|
|
assert resp["status"] == "ok"
|
|
assert captured["path"] == "/v1/api/route/workstreams/ws1/close"
|
|
assert captured["body"] == {}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_cancel():
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["path"] = request.url.path
|
|
captured["body"] = json.loads(request.content)
|
|
return _json_response({"status": "ok"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
await client.route_cancel("ws1", force=True)
|
|
assert captured["path"] == "/v1/api/route/workstreams/ws1/cancel"
|
|
assert captured["body"] == {"force": True}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_cancel_omits_force_when_false():
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["path"] = request.url.path
|
|
captured["body"] = json.loads(request.content)
|
|
return _json_response({"status": "ok"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
await client.route_cancel("ws1")
|
|
assert captured["path"] == "/v1/api/route/workstreams/ws1/cancel"
|
|
assert captured["body"] == {}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_command():
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["path"] = request.url.path
|
|
captured["body"] = json.loads(request.content)
|
|
return _json_response({"status": "ok"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
await client.route_command(ws_id="ws1", command="/clear")
|
|
assert captured["path"] == "/v1/api/route/command"
|
|
assert captured["body"] == {"ws_id": "ws1", "command": "/clear"}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_lookup():
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["path"] = request.url.path
|
|
captured["url"] = str(request.url)
|
|
return _json_response({"node_url": "http://n1:8080", "node_id": "n1"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.route_lookup("ws1")
|
|
assert resp["node_url"] == "http://n1:8080"
|
|
assert resp["node_id"] == "n1"
|
|
assert captured["path"] == "/v1/api/route"
|
|
assert "ws_id=ws1" in captured["url"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_route_workstream_live():
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["path"] = request.url.path
|
|
return _json_response({"ws_id": "ws1", "live": True})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
resp = await client.route_workstream_live("ws1")
|
|
|
|
assert resp.ws_id == "ws1"
|
|
assert resp.live is True
|
|
assert captured["path"] == "/v1/api/route/workstreams/ws1/live"
|