Files
turnstone/tests/test_workstream_endpoints.py
T
Patrick Buckley 3bdcf9870e fix(server): close review cleanup items from PRs #374 / #375 review (#376)
Third and final PR of the retrospective-review series.  Addresses the
remaining bug / perf / doc findings from the original multi-stage review
plus the three inline comments left on #374 and #375.

From the original review:

- bug-3: delete_workstream now nulls out parent_ws_id on every child
  row before dropping the target — previously, deleting a coordinator
  left orphaned parent_ws_id pointers and list_workstreams(parent_ws_id=
  <deleted>) kept returning ghost-parented rows.  Fix lives at the
  storage edge so both SQLite and PostgreSQL benefit without a schema
  migration.
- perf-1 / perf-2 / perf-3: new migration 041 drops the low-cardinality
  idx_workstreams_kind outright, rebuilds idx_workstreams_parent as a
  partial index (WHERE parent_ws_id IS NOT NULL) to halve its btree,
  and uses CREATE INDEX CONCURRENTLY on postgres so the rebuild
  doesn't take ACCESS EXCLUSIVE on populated tables.  Dialect-guarded;
  sqlite path is a straight partial CREATE INDEX.
- perf-5: _rebuild_children_from_storage bumps its limit sentinel to
  10_000 and logs a warning when the cap is hit instead of silently
  truncating the tail on every console cold-start.
- q-2: turnstone.core.memory.list_workstreams wrapper deleted (zero
  live callers; PR #374 kept it forward-compatible with the new
  kwargs as a stepping stone).
- q-5: migration 039's docstring now warns operators that downgrade
  drops parent_ws_id irreversibly and notes the 041 dependency.
- q-7: GET /v1/api/workstreams row shape now includes kind +
  parent_ws_id to match /v1/api/dashboard; the Pydantic
  WorkstreamInfo schema follows so SDK consumers see the same fields.

Inline review comments:

- #374 (copilot): console/server.py::coordinator_children now pushes
  user_id into the SQL filter for non-admin callers, so forged /
  migration-era rows with matching parent_ws_id but a different
  owner can't leak through.  Admins bypass the filter — they're
  expected to see the full subtree.
- #375 (copilot, delete handler): storage.get_workstream(ws_id) for
  the audit snapshot moved inside the try: block so a transient DB
  error surfaces through the endpoint's redacted 500 handler instead
  of an unhandled exception.
- #375 (copilot, _require_ws_access): added optional mgr= kwarg —
  when the workstream is live in the in-memory manager, trust its
  cached user_id instead of round-tripping storage.  In-memory-only
  handlers (approve / plan / cancel / command / close / events_sse /
  refresh-title / set-title) pass mgr= so they stay functional
  during transient DB outages and skip one query on the hot path.
  Storage-backed handlers (/delete, /open) omit mgr= and keep the
  storage path for persisted-but-not-loaded rows.

Tests:

- tests/test_workstream_kind.py adds regression tests for the cascade
  null-out on delete and the new user_id SQL filter.
- tests/test_workstream_endpoints.py updated so the title-handler
  tests exercise the in-memory fast path (MagicMock manager returning
  None falls through to storage; explicit ws.user_id set where the
  mock ws is used).

Lint (ruff), typecheck (strict mypy), pytest -m 'not live' all green
(4209 passing).
2026-04-17 22:42:46 -07:00

408 lines
14 KiB
Python

"""Tests for workstream management endpoints added in PRs #314-#315."""
from __future__ import annotations
import queue
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
if TYPE_CHECKING:
from starlette.requests import Request
from starlette.responses import Response
from turnstone.core.auth import AuthResult
from turnstone.core.storage._sqlite import SQLiteBackend
from turnstone.server import (
delete_workstream_endpoint,
list_interface_settings,
open_workstream,
refresh_workstream_title,
set_workstream_title,
update_interface_setting,
)
# ---------------------------------------------------------------------------
# Auth bypass middleware
# ---------------------------------------------------------------------------
class _InjectAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="test-user",
scopes=frozenset({"approve"}),
token_source="config",
permissions=frozenset({"read", "write", "approve"}),
)
return await call_next(request)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def storage(tmp_path):
return SQLiteBackend(str(tmp_path / "test.db"))
@pytest.fixture
def _inject_storage(storage):
"""Swap global storage registry for the test backend."""
import turnstone.core.storage._registry as reg
old = reg._storage
reg._storage = storage
yield storage
reg._storage = old
@pytest.fixture
def delete_client(_inject_storage):
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route(
"/api/workstreams/{ws_id}/delete",
delete_workstream_endpoint,
methods=["POST"],
),
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
return TestClient(app)
@pytest.fixture
def title_client(_inject_storage):
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route(
"/api/workstreams/{ws_id}/title",
set_workstream_title,
methods=["POST"],
),
Route(
"/api/workstreams/{ws_id}/refresh-title",
refresh_workstream_title,
methods=["POST"],
),
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
mock_mgr = MagicMock()
app.state.workstreams = mock_mgr
return TestClient(app), mock_mgr
@pytest.fixture
def open_client(_inject_storage):
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route(
"/api/workstreams/{ws_id}/open",
open_workstream,
methods=["POST"],
),
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
mock_mgr = MagicMock()
app.state.workstreams = mock_mgr
gq: queue.Queue[dict[str, Any]] = queue.Queue()
app.state.global_queue = gq
return TestClient(app), mock_mgr, gq
@pytest.fixture
def settings_client(_inject_storage):
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route("/api/admin/settings", list_interface_settings),
Route(
"/api/admin/settings/{key:path}",
update_interface_setting,
methods=["POST", "PUT"],
),
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
app.state.config_store = None
app.state.global_queue = queue.Queue()
return TestClient(app)
# ===========================================================================
# DELETE workstream
# ===========================================================================
class TestDeleteWorkstream:
def test_delete_success(self, delete_client, storage):
storage.register_workstream("ws-abc", "node-1", name="test")
r = delete_client.post("/v1/api/workstreams/ws-abc/delete")
assert r.status_code == 200
assert r.json()["deleted"] == "ws-abc"
def test_delete_not_found(self, delete_client):
r = delete_client.post("/v1/api/workstreams/nonexistent/delete")
assert r.status_code == 404
assert "not found" in r.json()["error"].lower()
def test_delete_error_redacted(self, delete_client, storage):
"""500 response should not leak exception internals."""
storage.register_workstream("ws-abc", "node-1", name="test", user_id="test-user")
with patch(
"turnstone.core.memory.delete_workstream",
side_effect=RuntimeError("secret internal detail"),
):
r = delete_client.post("/v1/api/workstreams/ws-abc/delete")
assert r.status_code == 500
assert "Delete failed" in r.json()["error"]
assert "secret" not in r.json()["error"]
# ===========================================================================
# SET title
# ===========================================================================
class TestSetWorkstreamTitle:
def test_set_title_success(self, title_client, storage):
client, mock_mgr = title_client
storage.register_workstream("ws-abc", "node-1", name="test", user_id="test-user")
# mgr.get returning None makes _require_ws_access fall through to
# the storage-backed ownership check (caller == "test-user" matches
# the registered owner). Tests that need a ws returned from the
# manager set up mock_ws.user_id explicitly.
mock_mgr.get.return_value = None
r = client.post(
"/v1/api/workstreams/ws-abc/title",
json={"title": "New Title"},
)
assert r.status_code == 200
assert r.json()["title"] == "New Title"
def test_set_title_empty(self, title_client, storage):
client, mock_mgr = title_client
storage.register_workstream("ws-abc", "node-1", name="test", user_id="test-user")
mock_mgr.get.return_value = None
r = client.post(
"/v1/api/workstreams/ws-abc/title",
json={"title": ""},
)
assert r.status_code == 400
assert "required" in r.json()["error"].lower()
def test_set_title_missing_body(self, title_client, storage):
client, mock_mgr = title_client
storage.register_workstream("ws-abc", "node-1", name="test", user_id="test-user")
mock_mgr.get.return_value = None
r = client.post(
"/v1/api/workstreams/ws-abc/title",
json={},
)
assert r.status_code == 400
def test_set_title_truncation(self, title_client, storage):
client, mock_mgr = title_client
storage.register_workstream("ws-abc", "node-1", name="test", user_id="test-user")
mock_mgr.get.return_value = None
long_title = "x" * 200
r = client.post(
"/v1/api/workstreams/ws-abc/title",
json={"title": long_title},
)
assert r.status_code == 200
assert len(r.json()["title"]) <= 80
def test_set_title_alias_conflict(self, title_client, storage):
client, mock_mgr = title_client
storage.register_workstream("ws-1", "node-1", name="first", user_id="test-user")
storage.register_workstream("ws-2", "node-1", name="second", user_id="test-user")
storage.set_workstream_alias("ws-1", "taken-name")
mock_mgr.get.return_value = None
r = client.post(
"/v1/api/workstreams/ws-2/title",
json={"title": "taken-name"},
)
assert r.status_code == 409
# ===========================================================================
# REFRESH title
# ===========================================================================
class TestRefreshWorkstreamTitle:
def test_refresh_success(self, title_client, storage):
client, mock_mgr = title_client
storage.register_workstream("ws-abc", "node-1", name="test", user_id="test-user")
# The in-memory fast path on _require_ws_access checks ws.user_id
# before falling back to storage, so the mock returned by
# mgr.get must carry the expected owner.
mock_ws = MagicMock()
mock_ws.user_id = "test-user"
mock_ws.session = MagicMock()
mock_mgr.get.return_value = mock_ws
with patch("turnstone.core.memory.get_workstream_display_name", return_value="Old Title"):
r = client.post("/v1/api/workstreams/ws-abc/refresh-title")
assert r.status_code == 200
mock_ws.session.request_title_refresh.assert_called_once_with("Old Title")
def test_refresh_not_found(self, title_client):
client, mock_mgr = title_client
mock_mgr.get.return_value = None
r = client.post("/v1/api/workstreams/ws-abc/refresh-title")
assert r.status_code == 404
def test_refresh_no_session(self, title_client):
client, mock_mgr = title_client
mock_ws = MagicMock()
mock_ws.session = None
mock_mgr.get.return_value = mock_ws
r = client.post("/v1/api/workstreams/ws-abc/refresh-title")
assert r.status_code == 404
# ===========================================================================
# OPEN workstream
# ===========================================================================
class TestOpenWorkstream:
@patch("turnstone.core.memory.resolve_workstream")
def test_open_already_loaded(self, mock_resolve, open_client):
client, mock_mgr, gq = open_client
mock_resolve.return_value = "ws-abc"
mock_ws = MagicMock()
mock_ws.id = "ws-abc"
mock_mgr.get.return_value = mock_ws
with patch("turnstone.core.memory.get_workstream_display_name", return_value="My WS"):
r = client.post("/v1/api/workstreams/ws-abc/open")
assert r.status_code == 200
assert r.json()["already_loaded"] is True
assert r.json()["ws_id"] == "ws-abc"
@patch("turnstone.core.memory.resolve_workstream")
def test_open_not_found(self, mock_resolve, open_client):
client, mock_mgr, gq = open_client
mock_resolve.return_value = None
r = client.post("/v1/api/workstreams/nonexistent/open")
assert r.status_code == 404
@patch("turnstone.core.memory.resolve_workstream")
def test_open_no_storage_row(self, mock_resolve, open_client, _inject_storage):
client, mock_mgr, gq = open_client
mock_resolve.return_value = "ws-abc"
mock_mgr.get.return_value = None # not loaded
# Storage has no row for ws-abc
r = client.post("/v1/api/workstreams/ws-abc/open")
assert r.status_code == 404
assert "storage" in r.json()["error"].lower()
# ===========================================================================
# LIST interface settings
# ===========================================================================
class TestListInterfaceSettings:
def test_list_defaults(self, settings_client):
r = settings_client.get("/v1/api/admin/settings")
assert r.status_code == 200
settings = r.json()["settings"]
keys = [s["key"] for s in settings]
assert "interface.theme" in keys
assert "interface.close_tab_action" in keys
# All should be defaults when no config store
for s in settings:
assert s["source"] == "default"
def test_list_only_interface_keys(self, settings_client):
r = settings_client.get("/v1/api/admin/settings")
settings = r.json()["settings"]
for s in settings:
assert s["key"].startswith("interface.")
# ===========================================================================
# UPDATE interface setting
# ===========================================================================
class TestUpdateInterfaceSetting:
def test_update_theme(self, settings_client, _inject_storage):
r = settings_client.post(
"/v1/api/admin/settings/interface.theme",
json={"value": "light"},
)
assert r.status_code == 200
assert r.json()["value"] == "light"
def test_update_via_put(self, settings_client, _inject_storage):
r = settings_client.put(
"/v1/api/admin/settings/interface.theme",
json={"value": "dark"},
)
assert r.status_code == 200
assert r.json()["value"] == "dark"
def test_reject_non_interface_key(self, settings_client):
r = settings_client.post(
"/v1/api/admin/settings/judge.enabled",
json={"value": True},
)
assert r.status_code == 400
assert "interface" in r.json()["error"].lower()
def test_reject_unknown_key(self, settings_client):
r = settings_client.post(
"/v1/api/admin/settings/interface.nonexistent",
json={"value": "x"},
)
assert r.status_code == 400
assert "unknown" in r.json()["error"].lower()
def test_reject_missing_value(self, settings_client):
r = settings_client.post(
"/v1/api/admin/settings/interface.theme",
json={},
)
assert r.status_code == 400
assert "value" in r.json()["error"].lower()
def test_reject_invalid_choice(self, settings_client):
r = settings_client.post(
"/v1/api/admin/settings/interface.theme",
json={"value": "neon-pink"},
)
assert r.status_code == 400