mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
3bdcf9870e
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).
408 lines
14 KiB
Python
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
|