Files
turnstone/tests/test_session_routes.py
Patrick Buckley 1358121d52 chore(tests): refresh fixtures for path-keyed URL family
Mechanical updates across the test suite to swap legacy
/v1/api/{send,approve,cancel,events,workstreams/close} URLs for the
path-keyed equivalents under /v1/api/workstreams/{ws_id}/<verb>, and
to drop ws_id from request bodies (the path provides it now).

Per file:

- test_session_routes.py: deletes test_close_legacy_mounts_when_handler_provided
  (the close_legacy slot is gone); test_send_mounts_post_and_delete_when_dequeue_provided
  (added in PR commit 1) stays.
- test_openapi.py: expected-paths set swaps to path-keyed shape;
  test_send_endpoint_has_request_body now asserts the OpenAPI for
  /v1/api/workstreams/{ws_id}/send.
- test_auth.py / test_auth_identity.py: required_scope and
  check_request fixtures swap to path-keyed shape; new tests cover
  write/approve/read scope assignment for the path-keyed verbs +
  the /node/* proxy mirror.
- test_sdk_server.py / test_sdk_console.py: mock-transport URL keys
  swap; bodies drop ws_id.
- test_server_attachments_endpoints.py: ~17 send sites migrated to
  /v1/api/workstreams/<ws>/send (a small Python script ran the bulk
  rewrite — body ws_id stripped, URL rebuilt).
- test_server_authz.py: cross-tenant approve/close/cancel/events
  tests retargeted to path-keyed URLs;
  test_events_legacy_query_keyed_url_still_resolves_to_404_for_unknown_ws
  renamed to test_events_path_keyed_url_resolves_to_404_for_unknown_ws
  with the docstring updated to note the legacy adapter is gone.
- test_close_reason_persistence.py: 7 close sites all swap.
- test_console_routing_proxy.py: route-proxy tests swap to
  /v1/api/route/workstreams/{ws_id}/<verb>; the upstream-URL
  assertion now reads from .request (route_proxy uses
  client.request(method, url, ...) for method passthrough); _wire_proxy
  helper installs both .post and .request mocks for compatibility.
- test_route_proxy_audit.py: parametrized URLs migrated;
  _make_proxy now also exposes a .request side-effect that delegates
  to .post for the same compatibility surface.
- test_api_versioning.py: openapi.json path assertion swaps to the
  path-keyed shape.

4557 passing under -m "not live"; ruff + mypy clean.
2026-04-26 22:14:22 -07:00

212 lines
7.8 KiB
Python

"""Tests for the shared session HTTP route registrar.
Verifies that :func:`turnstone.core.session_routes.register_session_routes`
and :func:`turnstone.core.session_routes.register_coord_verbs` mount
the right route table per the supplied handler bundles, and that the
console's ``create_app`` exposes the unified ``/v1/api/workstreams/``
URL shape (the legacy ``/v1/api/coordinator/`` shape is gone).
Body-level behavior is covered by the per-kind endpoint tests
(``tests/test_workstream_endpoints.py``,
``tests/test_coordinator_endpoints.py``); this module checks only the
routing surface.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from starlette.responses import JSONResponse
from starlette.routing import Route
from turnstone.core.session_routes import (
AttachmentHandlers,
CoordOnlyVerbHandlers,
SharedSessionVerbHandlers,
register_coord_verbs,
register_session_routes,
)
if TYPE_CHECKING:
from starlette.requests import Request
async def _stub(_request: Request) -> JSONResponse:
return JSONResponse({"ok": True})
def _attach() -> AttachmentHandlers:
return AttachmentHandlers(upload=_stub, list=_stub, get_content=_stub, delete=_stub)
def _route_paths(routes: list[Any]) -> list[tuple[str, frozenset[str]]]:
out = []
for r in routes:
assert isinstance(r, Route)
out.append((r.path, frozenset(r.methods or set())))
return out
def test_empty_handlers_register_no_routes() -> None:
"""A handler bundle with everything ``None`` mounts zero routes."""
routes: list[Any] = []
register_session_routes(
routes,
prefix="/api/workstreams",
handlers=SharedSessionVerbHandlers(),
)
assert routes == []
def test_saved_registers_before_detail() -> None:
"""Literal ``saved`` must register before bare ``{ws_id}`` so
Starlette doesn't match "saved" as a ws_id path param."""
routes: list[Any] = []
register_session_routes(
routes,
prefix="/api/workstreams",
handlers=SharedSessionVerbHandlers(
list_saved=_stub,
detail=_stub,
),
)
paths = [r.path for r in routes if isinstance(r, Route)]
assert paths.index("/api/workstreams/saved") < paths.index("/api/workstreams/{ws_id}")
def test_specific_verbs_register_before_bare_detail() -> None:
"""Per-verb ``{ws_id}/{verb}`` patterns must register before the
bare ``{ws_id}`` GET so Starlette routes verb requests to the
right handler."""
routes: list[Any] = []
register_session_routes(
routes,
prefix="/api/workstreams",
handlers=SharedSessionVerbHandlers(
detail=_stub,
close=_stub,
send=_stub,
events=_stub,
),
)
paths = [r.path for r in routes if isinstance(r, Route)]
detail_idx = paths.index("/api/workstreams/{ws_id}")
assert paths.index("/api/workstreams/{ws_id}/close") < detail_idx
assert paths.index("/api/workstreams/{ws_id}/send") < detail_idx
assert paths.index("/api/workstreams/{ws_id}/events") < detail_idx
def test_attachment_routes_mount_when_quartet_provided() -> None:
"""All four attachment routes mount when ``handlers.attachments``
is non-``None`` — the type system requires the four-handler
quartet to be set together."""
routes: list[Any] = []
register_session_routes(
routes,
prefix="/api/workstreams",
handlers=SharedSessionVerbHandlers(attachments=_attach()),
)
paths = {(p, m) for p, m in _route_paths(routes)}
assert ("/api/workstreams/{ws_id}/attachments", frozenset({"POST"})) in paths
assert ("/api/workstreams/{ws_id}/attachments", frozenset({"GET", "HEAD"})) in paths
assert (
"/api/workstreams/{ws_id}/attachments/{attachment_id}/content",
frozenset({"GET", "HEAD"}),
) in paths
assert (
"/api/workstreams/{ws_id}/attachments/{attachment_id}",
frozenset({"DELETE"}),
) in paths
def test_send_mounts_post_and_delete_when_dequeue_provided() -> None:
"""``handlers.send`` mounts POST {prefix}/{ws_id}/send and
``handlers.dequeue`` mounts DELETE on the same path. The two
routes register as separate ``Route`` entries with disjoint
method sets — Starlette dispatches by (path, method)."""
routes: list[Any] = []
register_session_routes(
routes,
prefix="/api/workstreams",
handlers=SharedSessionVerbHandlers(send=_stub, dequeue=_stub),
)
paths = {(p, m) for p, m in _route_paths(routes)}
assert ("/api/workstreams/{ws_id}/send", frozenset({"POST"})) in paths
assert ("/api/workstreams/{ws_id}/send", frozenset({"DELETE"})) in paths
# ``dequeue`` is independent of ``send`` — providing it alone
# mounts only the DELETE half (no POST regression).
routes_dequeue_only: list[Any] = []
register_session_routes(
routes_dequeue_only,
prefix="/api/workstreams",
handlers=SharedSessionVerbHandlers(dequeue=_stub),
)
paths_dequeue_only = {(p, m) for p, m in _route_paths(routes_dequeue_only)}
assert ("/api/workstreams/{ws_id}/send", frozenset({"DELETE"})) in paths_dequeue_only
assert ("/api/workstreams/{ws_id}/send", frozenset({"POST"})) not in paths_dequeue_only
def test_register_coord_verbs_mounts_seven_paths() -> None:
"""``register_coord_verbs`` mounts the seven coord-only verbs
at the unified prefix."""
routes: list[Any] = []
register_coord_verbs(
routes,
prefix="/api/workstreams",
handlers=CoordOnlyVerbHandlers(
children=_stub,
tasks=_stub,
metrics=_stub,
trust=_stub,
restrict=_stub,
stop_cascade=_stub,
close_all_children=_stub,
),
)
paths = {(p, m) for p, m in _route_paths(routes)}
assert paths == {
("/api/workstreams/{ws_id}/children", frozenset({"GET", "HEAD"})),
("/api/workstreams/{ws_id}/tasks", frozenset({"GET", "HEAD"})),
("/api/workstreams/{ws_id}/metrics", frozenset({"GET", "HEAD"})),
("/api/workstreams/{ws_id}/trust", frozenset({"POST"})),
("/api/workstreams/{ws_id}/restrict", frozenset({"POST"})),
("/api/workstreams/{ws_id}/stop_cascade", frozenset({"POST"})),
("/api/workstreams/{ws_id}/close_all_children", frozenset({"POST"})),
}
def test_console_create_app_only_mounts_unified_workstream_paths() -> None:
"""The console's ``create_app`` mounts coord verbs only at the
unified ``/api/workstreams/`` shape — no path under
``/api/coordinator/`` should remain (deleted in Step 0.4)."""
from tests._coord_test_helpers import MockStorage
from turnstone.console.collector import ClusterCollector
from turnstone.console.server import create_app
collector = ClusterCollector(storage=MockStorage(), discovery_interval=999)
app = create_app(collector=collector)
paths: set[str] = set()
def _walk(routes: Any) -> None:
for r in routes:
if hasattr(r, "path"):
paths.add(r.path)
sub = getattr(r, "routes", None)
if sub:
_walk(sub)
_walk(app.routes)
assert not any("/api/coordinator" in p for p in paths), (
f"legacy /api/coordinator paths still mounted: "
f"{sorted(p for p in paths if '/api/coordinator' in p)}"
)
assert any(p.endswith("/api/workstreams") for p in paths)
# Spot-check one verb per category from the registrar.
assert any(p.endswith("/api/workstreams/{ws_id}/send") for p in paths)
assert any(p.endswith("/api/workstreams/{ws_id}/events") for p in paths)
assert any(p.endswith("/api/workstreams/{ws_id}") for p in paths)
# And one from the coord-only registrar.
assert any(p.endswith("/api/workstreams/{ws_id}/trust") for p in paths)
assert any(p.endswith("/api/workstreams/{ws_id}/close_all_children") for p in paths)