Files
ZY-Agent/backend/tests/test_workspace_membership_repo.py
T
1445043649 36ffe2713f feat(persistence): WorkspaceMembershipRepository + 8 unit tests
新建 backend/packages/harness/deerflow/persistence/workspace_membership/sql.py,方法:
  - add(workspace_id, user_id, role, invited_by=None) 校验 role ∈
    {owner, admin, member}(Stage 0 仅写 owner,schema 已支持其余)
  - remove(workspace_id, user_id) → bool(rowcount > 0)
  - list_by_user(user_id) 按 joined_at desc
  - list_by_workspace(workspace_id) 按 joined_at asc
  - get_role(workspace_id, user_id) → str | None
  - change_role(workspace_id, user_id, new_role) → bool

MembershipValidationError 自定义异常(role 非法)。

8 test 覆盖:
  - add → get_role round-trip
  - remove 命中/未命中返回 True/False
  - 同 workspace 第 2 个 owner 触发 IntegrityError(partial unique)
  - 同 workspace 多个 admin 不触发(Stage 2 forward compat)
  - CASCADE: 删 user 自动清理 memberships
  - list_by_user 按 joined_at desc 排序(多 workspace)
  - role 校验拒绝 'viewer'
  - change_role 命中改值 + 未命中返 False

注:owner 转让需要事务内两行原子 swap(先把现 owner 改 admin,再把新 owner
插 owner),放 PR4+ auth router 内做带权限检查的版本;本仓储不暴露
transfer_ownership 方法以保持单一职责。

Stage 0 PR3 T3.7。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:13:52 +08:00

217 lines
8.0 KiB
Python

"""Tests for WorkspaceMembershipRepository (Stage 0 PR3 T3.7)."""
from __future__ import annotations
import pytest
from sqlalchemy.exc import IntegrityError
from deerflow.persistence.user.model import UserRow
from deerflow.persistence.workspace import WorkspaceRepository
from deerflow.persistence.workspace_membership import (
MembershipValidationError,
WorkspaceMembershipRepository,
)
pytestmark = pytest.mark.anyio
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
async def _setup(tmp_path):
"""Create both repos against a fresh SQLite DB."""
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))
sf = get_session_factory()
return (
WorkspaceRepository(sf),
WorkspaceMembershipRepository(sf),
sf,
)
async def _cleanup():
from deerflow.persistence.engine import close_engine
await close_engine()
async def _seed_user(sf, user_id: str, email: str) -> None:
async with sf() as session:
session.add(UserRow(id=user_id, email=email))
await session.commit()
# ---------------------------------------------------------------------------
# add / remove smoke
# ---------------------------------------------------------------------------
async def test_add_then_get_role(tmp_path):
ws_repo, m_repo, sf = await _setup(tmp_path)
try:
await _seed_user(sf, "u-1", "u1@example.com")
ws = await ws_repo.create(name="W1", slug="w-1", owner_id="u-1")
added = await m_repo.add(workspace_id=ws["id"], user_id="u-1", role="owner")
assert added["role"] == "owner"
role = await m_repo.get_role(workspace_id=ws["id"], user_id="u-1")
assert role == "owner"
finally:
await _cleanup()
async def test_remove_returns_true_when_deleted_false_when_missing(tmp_path):
ws_repo, m_repo, sf = await _setup(tmp_path)
try:
await _seed_user(sf, "u-1", "u1@example.com")
ws = await ws_repo.create(name="W1", slug="rm-w", owner_id="u-1")
await m_repo.add(workspace_id=ws["id"], user_id="u-1", role="owner")
assert await m_repo.remove(workspace_id=ws["id"], user_id="u-1") is True
# second remove of same row -> nothing to delete
assert await m_repo.remove(workspace_id=ws["id"], user_id="u-1") is False
finally:
await _cleanup()
# ---------------------------------------------------------------------------
# partial unique: exactly one owner per workspace
# ---------------------------------------------------------------------------
async def test_cannot_add_second_owner(tmp_path):
"""Inserting a second role='owner' in the same workspace must raise IntegrityError."""
ws_repo, m_repo, sf = await _setup(tmp_path)
try:
await _seed_user(sf, "u-1", "u1@example.com")
await _seed_user(sf, "u-2", "u2@example.com")
ws = await ws_repo.create(name="W", slug="one-owner", owner_id="u-1")
await m_repo.add(workspace_id=ws["id"], user_id="u-1", role="owner")
with pytest.raises(IntegrityError):
await m_repo.add(workspace_id=ws["id"], user_id="u-2", role="owner")
finally:
await _cleanup()
async def test_admin_and_member_dont_trigger_partial_unique(tmp_path):
"""Multiple admin/member rows in one workspace are fine (Stage 2 forward compat)."""
ws_repo, m_repo, sf = await _setup(tmp_path)
try:
await _seed_user(sf, "u-1", "u1@example.com")
await _seed_user(sf, "u-2", "u2@example.com")
await _seed_user(sf, "u-3", "u3@example.com")
ws = await ws_repo.create(name="W", slug="multi-admin", owner_id="u-1")
await m_repo.add(workspace_id=ws["id"], user_id="u-1", role="owner")
await m_repo.add(workspace_id=ws["id"], user_id="u-2", role="admin")
# Second admin OK
await m_repo.add(workspace_id=ws["id"], user_id="u-3", role="admin")
members = await m_repo.list_by_workspace(workspace_id=ws["id"])
assert len(members) == 3
finally:
await _cleanup()
# ---------------------------------------------------------------------------
# CASCADE: deleting a user wipes their memberships
# ---------------------------------------------------------------------------
async def test_cascade_delete_user_removes_memberships(tmp_path):
"""FK ON DELETE CASCADE on user_id."""
from sqlalchemy import delete
ws_repo, m_repo, sf = await _setup(tmp_path)
try:
await _seed_user(sf, "u-keep", "keep@example.com")
await _seed_user(sf, "u-purge", "purge@example.com")
ws = await ws_repo.create(name="W", slug="cascade", owner_id="u-keep")
await m_repo.add(workspace_id=ws["id"], user_id="u-keep", role="owner")
await m_repo.add(workspace_id=ws["id"], user_id="u-purge", role="admin")
async with sf() as session:
await session.execute(delete(UserRow).where(UserRow.id == "u-purge"))
await session.commit()
members = await m_repo.list_by_workspace(workspace_id=ws["id"])
member_ids = [m["user_id"] for m in members]
assert "u-purge" not in member_ids
assert "u-keep" in member_ids
finally:
await _cleanup()
# ---------------------------------------------------------------------------
# list_by_user ordering (most recent joined_at first)
# ---------------------------------------------------------------------------
async def test_list_by_user_orders_recent_first(tmp_path):
import asyncio
ws_repo, m_repo, sf = await _setup(tmp_path)
try:
await _seed_user(sf, "u-1", "u1@example.com")
await _seed_user(sf, "u-2", "u2@example.com")
# u-1 owns workspace A
ws_a = await ws_repo.create(name="A", slug="ord-a", owner_id="u-1")
await m_repo.add(workspace_id=ws_a["id"], user_id="u-1", role="owner")
await asyncio.sleep(0.01) # ensure distinct joined_at
# u-1 later joins workspace C as a member (u-2 owns it)
ws_c = await ws_repo.create(name="C", slug="ord-c", owner_id="u-2")
await m_repo.add(workspace_id=ws_c["id"], user_id="u-1", role="member")
memberships = await m_repo.list_by_user(user_id="u-1")
slugs_in_order = [(m["workspace_id"], m["role"]) for m in memberships]
# 'ws_c member' joined AFTER 'ws_a owner' → ws_c first
assert slugs_in_order[0] == (ws_c["id"], "member")
assert slugs_in_order[1] == (ws_a["id"], "owner")
finally:
await _cleanup()
# ---------------------------------------------------------------------------
# role validation + change_role
# ---------------------------------------------------------------------------
async def test_add_rejects_unknown_role(tmp_path):
ws_repo, m_repo, sf = await _setup(tmp_path)
try:
await _seed_user(sf, "u-1", "u1@example.com")
ws = await ws_repo.create(name="W", slug="invrole", owner_id="u-1")
with pytest.raises(MembershipValidationError, match="allowed set"):
await m_repo.add(workspace_id=ws["id"], user_id="u-1", role="viewer")
finally:
await _cleanup()
async def test_change_role_admin_to_member(tmp_path):
ws_repo, m_repo, sf = await _setup(tmp_path)
try:
await _seed_user(sf, "u-1", "u1@example.com")
await _seed_user(sf, "u-2", "u2@example.com")
ws = await ws_repo.create(name="W", slug="chg", owner_id="u-1")
await m_repo.add(workspace_id=ws["id"], user_id="u-1", role="owner")
await m_repo.add(workspace_id=ws["id"], user_id="u-2", role="admin")
ok = await m_repo.change_role(workspace_id=ws["id"], user_id="u-2", new_role="member")
assert ok is True
assert await m_repo.get_role(workspace_id=ws["id"], user_id="u-2") == "member"
# change_role on a non-member returns False
miss = await m_repo.change_role(workspace_id=ws["id"], user_id="u-nonexistent", new_role="member")
assert miss is False
finally:
await _cleanup()