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).
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""Tests for WorkspaceRepository (Stage 0 PR3).
|
|
|
|
Pattern mirrors :mod:`test_feedback`: SQLite ephemeral DB per test via
|
|
tmp_path, no real Postgres needed at the unit-test layer. Partial-unique
|
|
double-driver validation lives in :mod:`test_workspace_partial_unique`
|
|
(T3.8, runs against both backends).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from deerflow.persistence.user.model import UserRow
|
|
from deerflow.persistence.workspace import WorkspaceRepository, WorkspaceValidationError
|
|
|
|
|
|
async def _make_repo(tmp_path):
|
|
from deerflow.persistence.engine import get_session_factory, init_engine
|
|
|
|
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
|
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
|
return WorkspaceRepository(get_session_factory())
|
|
|
|
|
|
async def _cleanup():
|
|
from deerflow.persistence.engine import close_engine
|
|
|
|
await close_engine()
|
|
|
|
|
|
async def _seed_user(repo, user_id: str = "u-alice", email: str = "alice@example.com") -> None:
|
|
"""Create a user row so workspace.owner_id FK is satisfied."""
|
|
async with repo._sf() as session:
|
|
session.add(UserRow(id=user_id, email=email))
|
|
await session.commit()
|
|
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
@pytest.fixture
|
|
def anyio_backend() -> str:
|
|
return "asyncio"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# create / get_by_slug round-trip
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_create_then_lookup_by_slug(tmp_path):
|
|
repo = await _make_repo(tmp_path)
|
|
try:
|
|
await _seed_user(repo)
|
|
created = await repo.create(name="Alice's Workspace", slug="alice", owner_id="u-alice")
|
|
assert created["slug"] == "alice"
|
|
assert created["status"] == "active"
|
|
assert created["owner_id"] == "u-alice"
|
|
assert len(created["id"]) == 36 # UUID v4
|
|
|
|
fetched = await repo.get_by_slug("alice")
|
|
assert fetched is not None
|
|
assert fetched["id"] == created["id"]
|
|
finally:
|
|
await _cleanup()
|
|
|
|
|
|
async def test_get_by_slug_returns_none_when_missing(tmp_path):
|
|
repo = await _make_repo(tmp_path)
|
|
try:
|
|
assert await repo.get_by_slug("nonexistent") is None
|
|
finally:
|
|
await _cleanup()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# slug uniqueness + format + blacklist
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_create_rejects_duplicate_slug(tmp_path):
|
|
"""Two workspaces with the same slug — second raises IntegrityError."""
|
|
repo = await _make_repo(tmp_path)
|
|
try:
|
|
await _seed_user(repo)
|
|
await repo.create(name="A", slug="dup", owner_id="u-alice")
|
|
with pytest.raises(IntegrityError):
|
|
await repo.create(name="B", slug="dup", owner_id="u-alice")
|
|
finally:
|
|
await _cleanup()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_slug",
|
|
[
|
|
"ab", # too short
|
|
"x" * 33, # too long
|
|
"UPPER", # uppercase
|
|
"has space", # space
|
|
"-start-with-dash", # bad start
|
|
"end-with-dash-", # bad end
|
|
"double--dash", # consecutive dashes
|
|
"underscore_not_ok", # underscore
|
|
],
|
|
)
|
|
async def test_create_rejects_invalid_slug_pattern(tmp_path, bad_slug):
|
|
repo = await _make_repo(tmp_path)
|
|
try:
|
|
await _seed_user(repo)
|
|
with pytest.raises(WorkspaceValidationError, match="(pattern|length)"):
|
|
await repo.create(name="x", slug=bad_slug, owner_id="u-alice")
|
|
finally:
|
|
await _cleanup()
|
|
|
|
|
|
@pytest.mark.parametrize("reserved", ["admin", "api", "auth", "settings", "billing", "select-workspace"])
|
|
async def test_create_rejects_reserved_slug(tmp_path, reserved):
|
|
repo = await _make_repo(tmp_path)
|
|
try:
|
|
await _seed_user(repo)
|
|
with pytest.raises(WorkspaceValidationError, match="reserved"):
|
|
await repo.create(name="x", slug=reserved, owner_id="u-alice")
|
|
finally:
|
|
await _cleanup()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# status state machine
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_status_state_transitions(tmp_path):
|
|
"""active → suspended → deleted are all accepted."""
|
|
repo = await _make_repo(tmp_path)
|
|
try:
|
|
await _seed_user(repo)
|
|
ws = await repo.create(name="x", slug="trans", owner_id="u-alice")
|
|
assert ws["status"] == "active"
|
|
|
|
await repo.update_status(ws["id"], "suspended")
|
|
async with repo._sf() as session:
|
|
from deerflow.persistence.workspace.model import WorkspaceRow
|
|
|
|
row = await session.get(WorkspaceRow, ws["id"])
|
|
assert row.status == "suspended"
|
|
|
|
await repo.update_status(ws["id"], "deleted")
|
|
async with repo._sf() as session:
|
|
from deerflow.persistence.workspace.model import WorkspaceRow
|
|
|
|
row = await session.get(WorkspaceRow, ws["id"])
|
|
assert row.status == "deleted"
|
|
finally:
|
|
await _cleanup()
|
|
|
|
|
|
async def test_update_status_rejects_unknown_value(tmp_path):
|
|
repo = await _make_repo(tmp_path)
|
|
try:
|
|
await _seed_user(repo)
|
|
ws = await repo.create(name="x", slug="rejstat", owner_id="u-alice")
|
|
with pytest.raises(WorkspaceValidationError, match="allowed set"):
|
|
await repo.update_status(ws["id"], "weird-state")
|
|
finally:
|
|
await _cleanup()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CASCADE: workspace.delete() drops dependent memberships
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_delete_cascades_to_memberships(tmp_path):
|
|
"""Deleting a workspace removes its membership rows (FK CASCADE)."""
|
|
from sqlalchemy import select
|
|
|
|
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
|
|
|
|
repo = await _make_repo(tmp_path)
|
|
try:
|
|
await _seed_user(repo)
|
|
ws = await repo.create(name="x", slug="casc", owner_id="u-alice")
|
|
# Insert an owner membership manually (repository pattern is single-
|
|
# responsibility; registration flow will normally insert both rows
|
|
# in one transaction).
|
|
async with repo._sf() as session:
|
|
session.add(WorkspaceMembershipRow(workspace_id=ws["id"], user_id="u-alice", role="owner"))
|
|
await session.commit()
|
|
|
|
await repo.delete(ws["id"])
|
|
|
|
async with repo._sf() as session:
|
|
remaining = (await session.execute(select(WorkspaceMembershipRow).where(WorkspaceMembershipRow.workspace_id == ws["id"]))).scalars().all()
|
|
assert remaining == [], "memberships should be CASCADE-deleted with workspace"
|
|
finally:
|
|
await _cleanup()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# membership-aware get + list_by_user
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.no_auto_user
|
|
async def test_get_returns_none_for_non_member(tmp_path):
|
|
"""User-A creates a workspace; User-B's `get(wsA)` returns None."""
|
|
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
|
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
|
|
|
repo = await _make_repo(tmp_path)
|
|
try:
|
|
# Seed two users
|
|
async with repo._sf() as session:
|
|
session.add(UserRow(id="u-A", email="a@example.com"))
|
|
session.add(UserRow(id="u-B", email="b@example.com"))
|
|
await session.commit()
|
|
|
|
# User A creates workspace + becomes owner
|
|
ws = await repo.create(name="A's WS", slug="a-ws", owner_id="u-A")
|
|
async with repo._sf() as session:
|
|
session.add(WorkspaceMembershipRow(workspace_id=ws["id"], user_id="u-A", role="owner"))
|
|
await session.commit()
|
|
|
|
# User B attempts to read it via contextvar
|
|
user_b = SimpleNamespace(id="u-B")
|
|
token = set_current_user(user_b)
|
|
try:
|
|
assert await repo.get(ws["id"]) is None
|
|
finally:
|
|
reset_current_user(token)
|
|
|
|
# User A's own get succeeds
|
|
user_a = SimpleNamespace(id="u-A")
|
|
token = set_current_user(user_a)
|
|
try:
|
|
row = await repo.get(ws["id"])
|
|
assert row is not None
|
|
assert row["slug"] == "a-ws"
|
|
finally:
|
|
reset_current_user(token)
|
|
finally:
|
|
await _cleanup()
|
|
|
|
|
|
@pytest.mark.no_auto_user
|
|
async def test_list_by_user_excludes_other_workspaces(tmp_path):
|
|
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
|
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
|
|
|
repo = await _make_repo(tmp_path)
|
|
try:
|
|
async with repo._sf() as session:
|
|
session.add(UserRow(id="u-A", email="a@example.com"))
|
|
session.add(UserRow(id="u-B", email="b@example.com"))
|
|
await session.commit()
|
|
|
|
ws_a = await repo.create(name="A", slug="ws-a", owner_id="u-A")
|
|
ws_b = await repo.create(name="B", slug="ws-b", owner_id="u-B")
|
|
async with repo._sf() as session:
|
|
session.add(WorkspaceMembershipRow(workspace_id=ws_a["id"], user_id="u-A", role="owner"))
|
|
session.add(WorkspaceMembershipRow(workspace_id=ws_b["id"], user_id="u-B", role="owner"))
|
|
await session.commit()
|
|
|
|
token = set_current_user(SimpleNamespace(id="u-A"))
|
|
try:
|
|
workspaces = await repo.list_by_user()
|
|
assert [w["slug"] for w in workspaces] == ["ws-a"]
|
|
finally:
|
|
reset_current_user(token)
|
|
finally:
|
|
await _cleanup()
|
|
|
|
|
|
async def test_list_by_user_bypass_returns_all(tmp_path):
|
|
"""user_id=None opts out of membership filter (migration path)."""
|
|
repo = await _make_repo(tmp_path)
|
|
try:
|
|
await _seed_user(repo, "u-A", "a@example.com")
|
|
await _seed_user(repo, "u-B", "b@example.com")
|
|
await repo.create(name="A", slug="all-a", owner_id="u-A")
|
|
await repo.create(name="B", slug="all-b", owner_id="u-B")
|
|
|
|
workspaces = await repo.list_by_user(user_id=None)
|
|
# PR6 conftest auto-seeds an "autouse-test" workspace via the
|
|
# ``Base.metadata.after_create`` hook so business-row FKs resolve.
|
|
# ``user_id=None`` bypasses the membership filter, so it surfaces
|
|
# alongside the two rows the test inserted — that is the intended
|
|
# "no filter" behaviour. Assert the inserted ones are present.
|
|
slugs = sorted(w["slug"] for w in workspaces)
|
|
assert "all-a" in slugs
|
|
assert "all-b" in slugs
|
|
finally:
|
|
await _cleanup()
|