87ea715c2a
Flip the 4 business ORM models (ThreadMetaRow, RunRow, FeedbackRow, RunEventRow) to ``workspace_id: Mapped[str]`` with ``nullable=False``. PR5's alembic 0003 already enforces NOT NULL at the DB layer; this aligns the ORM-driven ``create_all()`` path (dev / tests) with the same invariant so a new install ends up at the post-0003 schema without running alembic. Test fallout absorbed: - `tests/conftest.py` autouse seed now produces a fully consistent pair: user row (default_workspace_id = test-workspace-autouse) plus the workspace itself. The PR5 backfill script's "users without default_workspace_id" query no longer picks the fixture up. Insert order is user → workspace → UPDATE user, walking around the chicken- and-egg FK between `workspaces.owner_id` and `users.default_workspace_id`. - `tests/test_backfill_workspace_id.py` adds a file-scoped autouse fixture that temporarily flips `column.nullable = True` for the four business tables (production correctness comes from alembic 0003; the script's own job is exactly to fill rows between 0002 and 0003 so its tests need that transient state to be representable). Its `_init_engine` also deletes the autouse seed rows to match the "fresh DB" model the tests assume. - `test_thread_meta_workspace_filter::test_create_workspace_none_bypasses` renamed to `test_create_workspace_none_rejected_by_orm` and asserts the new IntegrityError on explicit None — write paths can no longer bypass workspace scope. - 5 `test_workspace_context` tests + the auth-middleware reset test get `@pytest.mark.no_auto_workspace` so they keep testing the unset-contextvar path. - `test_workspace_repo::test_list_by_user_bypass_returns_all` switches to membership assertions instead of strict equality since the autouse fixture surfaces under `user_id=None`. 3214 passed, 30 skipped; the remaining 17 are the documented pre-existing caplog ordering flakes (all pass in isolation).
177 lines
6.0 KiB
Python
177 lines
6.0 KiB
Python
"""Tests for runtime.workspace_context — workspace contextvar semantics.
|
|
|
|
Mirrors :mod:`test_user_context` but for the workspace contextvar
|
|
introduced in Stage 0 PR3. No autouse workspace fixture exists yet
|
|
(PR4 will add it together with the AuthMiddleware injection), so these
|
|
tests run against a clean contextvar.
|
|
"""
|
|
|
|
import uuid
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from deerflow.runtime.workspace_context import (
|
|
AUTO,
|
|
DEFAULT_WORKSPACE_ID,
|
|
CurrentWorkspace,
|
|
get_current_workspace,
|
|
get_effective_workspace_id,
|
|
require_current_workspace,
|
|
reset_current_workspace,
|
|
resolve_workspace_id,
|
|
set_current_workspace,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_current_workspace / require_current_workspace / set+reset round-trip
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.no_auto_workspace
|
|
def test_default_is_none():
|
|
"""Before any set, contextvar returns None."""
|
|
assert get_current_workspace() is None
|
|
|
|
|
|
@pytest.mark.no_auto_workspace
|
|
def test_set_and_reset_roundtrip():
|
|
"""set_current_workspace returns a token that reset restores."""
|
|
workspace = SimpleNamespace(id="ws-1", role="owner")
|
|
token = set_current_workspace(workspace)
|
|
try:
|
|
assert get_current_workspace() is workspace
|
|
finally:
|
|
reset_current_workspace(token)
|
|
assert get_current_workspace() is None
|
|
|
|
|
|
@pytest.mark.no_auto_workspace
|
|
def test_require_current_workspace_raises_when_unset():
|
|
"""require_current_workspace raises RuntimeError if contextvar is unset."""
|
|
assert get_current_workspace() is None
|
|
with pytest.raises(RuntimeError, match="without workspace context"):
|
|
require_current_workspace()
|
|
|
|
|
|
def test_require_current_workspace_returns_workspace_when_set():
|
|
"""require_current_workspace returns the workspace when contextvar is set."""
|
|
workspace = SimpleNamespace(id="ws-2", role="admin")
|
|
token = set_current_workspace(workspace)
|
|
try:
|
|
assert require_current_workspace() is workspace
|
|
finally:
|
|
reset_current_workspace(token)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CurrentWorkspace Protocol — must require BOTH .id and .role
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_protocol_accepts_id_and_role():
|
|
"""CurrentWorkspace is satisfied by any object with .id and .role."""
|
|
workspace = SimpleNamespace(id="ws-3", role="member")
|
|
assert isinstance(workspace, CurrentWorkspace)
|
|
|
|
|
|
def test_protocol_rejects_missing_role():
|
|
"""An object with only .id (no .role) is NOT a workspace."""
|
|
user_shaped = SimpleNamespace(id="ws-4")
|
|
assert not isinstance(user_shaped, CurrentWorkspace)
|
|
|
|
|
|
def test_protocol_rejects_no_id():
|
|
"""An object without .id does not satisfy CurrentWorkspace."""
|
|
not_a_workspace = SimpleNamespace(role="owner")
|
|
assert not isinstance(not_a_workspace, CurrentWorkspace)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_effective_workspace_id / DEFAULT_WORKSPACE_ID tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_default_workspace_id_is_default():
|
|
assert DEFAULT_WORKSPACE_ID == "default"
|
|
|
|
|
|
@pytest.mark.no_auto_workspace
|
|
def test_effective_workspace_id_returns_default_when_no_workspace():
|
|
"""No workspace in context -> fallback to DEFAULT_WORKSPACE_ID."""
|
|
assert get_effective_workspace_id() == "default"
|
|
|
|
|
|
def test_effective_workspace_id_returns_workspace_id_when_set():
|
|
workspace = SimpleNamespace(id="ws-abc-123", role="owner")
|
|
token = set_current_workspace(workspace)
|
|
try:
|
|
assert get_effective_workspace_id() == "ws-abc-123"
|
|
finally:
|
|
reset_current_workspace(token)
|
|
|
|
|
|
def test_effective_workspace_id_coerces_to_str():
|
|
"""workspace.id might be a UUID object; must come back as str."""
|
|
wid = uuid.uuid4()
|
|
workspace = SimpleNamespace(id=wid, role="owner")
|
|
token = set_current_workspace(workspace)
|
|
try:
|
|
assert get_effective_workspace_id() == str(wid)
|
|
finally:
|
|
reset_current_workspace(token)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_workspace_id three-state semantics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_resolve_auto_reads_from_contextvar():
|
|
workspace = SimpleNamespace(id="ws-resolve-1", role="owner")
|
|
token = set_current_workspace(workspace)
|
|
try:
|
|
assert resolve_workspace_id(AUTO) == "ws-resolve-1"
|
|
finally:
|
|
reset_current_workspace(token)
|
|
|
|
|
|
@pytest.mark.no_auto_workspace
|
|
def test_resolve_auto_raises_when_unset():
|
|
assert get_current_workspace() is None
|
|
with pytest.raises(RuntimeError, match="workspace_id=AUTO but no workspace"):
|
|
resolve_workspace_id(AUTO, method_name="TestRepo.search")
|
|
|
|
|
|
def test_resolve_explicit_str_overrides_contextvar():
|
|
workspace = SimpleNamespace(id="ws-ctx", role="owner")
|
|
token = set_current_workspace(workspace)
|
|
try:
|
|
# Explicit value beats contextvar — admin override / test path.
|
|
assert resolve_workspace_id("ws-explicit") == "ws-explicit"
|
|
finally:
|
|
reset_current_workspace(token)
|
|
|
|
|
|
def test_resolve_explicit_none_means_no_filter():
|
|
workspace = SimpleNamespace(id="ws-ctx-2", role="owner")
|
|
token = set_current_workspace(workspace)
|
|
try:
|
|
# Explicit None opts out of workspace filtering (migration scripts).
|
|
assert resolve_workspace_id(None) is None
|
|
finally:
|
|
reset_current_workspace(token)
|
|
|
|
|
|
def test_resolve_auto_coerces_uuid_to_str():
|
|
"""resolve_workspace_id with AUTO returns str even if workspace.id is UUID."""
|
|
wid = uuid.uuid4()
|
|
workspace = SimpleNamespace(id=wid, role="owner")
|
|
token = set_current_workspace(workspace)
|
|
try:
|
|
resolved = resolve_workspace_id(AUTO)
|
|
assert resolved == str(wid)
|
|
assert isinstance(resolved, str)
|
|
finally:
|
|
reset_current_workspace(token)
|