Files
ZY-Agent/backend/tests/test_backfill_workspace_id.py
T
1445043649 87ea715c2a feat(persistence): PR6 T5.11 — ORM workspace_id nullable=False
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).
2026-05-13 18:06:53 +08:00

368 lines
17 KiB
Python

"""Tests for ``scripts/backfill_workspace_id.py`` (Stage 0 PR5).
Each step in the three-step backfill is exercised in isolation against
a SQLite-on-disk database. The script wires in ``app.gateway.auth.workspace_slug``,
so we get the same slug semantics that the registration flow uses.
Pattern mirrors :mod:`test_workspace_repo`: ``init_engine`` + per-test
tmp_path, with explicit ``close_engine`` teardown so the singleton
session factory does not leak across tests.
"""
from __future__ import annotations
import uuid
import pytest
from sqlalchemy import select
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.user.model import UserRow
from deerflow.persistence.workspace.model import WorkspaceRow
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
from scripts.backfill_workspace_id import (
LEGACY_WORKSPACE_ID,
_ensure_legacy_workspace,
_step1_create_workspaces_for_users,
_step2_update_table_from_users,
_step3_assign_legacy_workspace,
backfill,
)
pytestmark = pytest.mark.anyio
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
_BUSINESS_ROWS = (ThreadMetaRow, RunRow, FeedbackRow, RunEventRow)
@pytest.fixture(autouse=True)
def _relax_workspace_id_nullable():
"""Simulate alembic 0002 (pre-backfill) state during these tests.
PR6 T5.11 flipped ``workspace_id`` to ``nullable=False`` on the four
business ORM models — production correctness comes from alembic 0003.
The backfill script's job is precisely to fill the rows that were
inserted between 0002 (column added, nullable) and 0003 (NOT NULL),
so tests for it must be able to insert NULL rows. We mutate
``column.nullable`` for the four tables before ``create_all`` runs,
then restore on teardown so other tests see the production shape.
"""
saved: list[tuple] = []
for model in _BUSINESS_ROWS:
col = model.__table__.c.workspace_id
saved.append((col, col.nullable))
col.nullable = True
try:
yield
finally:
for col, original in saved:
col.nullable = original
async def _init_engine(tmp_path):
from sqlalchemy import delete
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()
# The PR6 conftest seeds an autouse user + workspace so business-row FKs
# resolve in the wider test suite. Backfill tests model "fresh DB needs
# backfill" semantics, so wipe those rows here. Order: clear the FK
# pointer first, then the rows.
async with sf() as session:
await session.execute(delete(WorkspaceMembershipRow))
await session.execute(delete(WorkspaceRow).where(WorkspaceRow.id == "test-workspace-autouse"))
await session.execute(delete(UserRow).where(UserRow.id == "test-user-autouse"))
await session.commit()
return sf
async def _close():
from deerflow.persistence.engine import close_engine
await close_engine()
async def _seed_user(sf, *, email: str, default_workspace_id: str | None = None, system_role: str = "user") -> str:
user_id = str(uuid.uuid4())
async with sf() as session:
session.add(UserRow(id=user_id, email=email, default_workspace_id=default_workspace_id, system_role=system_role))
await session.commit()
return user_id
async def _seed_business_row(sf, model, **fields) -> None:
async with sf() as session:
session.add(model(**fields))
await session.commit()
# ---------------------------------------------------------------------------
# Step 1: per-user workspace creation
# ---------------------------------------------------------------------------
async def test_creates_workspace_per_user_without_default(tmp_path):
"""Each user with NULL default_workspace_id gets a workspace + owner membership."""
sf = await _init_engine(tmp_path)
try:
u_alice = await _seed_user(sf, email="alice@example.com")
u_bob = await _seed_user(sf, email="bob+spam@example.com")
count = await _step1_create_workspaces_for_users(sf, dry_run=False)
assert count == 2
async with sf() as session:
workspaces = (await session.execute(select(WorkspaceRow))).scalars().all()
memberships = (await session.execute(select(WorkspaceMembershipRow))).scalars().all()
users = {u.id: u for u in (await session.execute(select(UserRow))).scalars().all()}
# 2 workspaces, each with exactly one owner membership matching its user.
assert len(workspaces) == 2
assert len(memberships) == 2
owners_by_ws = {m.workspace_id: m.user_id for m in memberships if m.role == "owner"}
assert {m.role for m in memberships} == {"owner"}
for ws in workspaces:
assert owners_by_ws[ws.id] == ws.owner_id
assert users[ws.owner_id].default_workspace_id == ws.id
# Slug semantics: alice@ → "alice", bob+spam@ → "bob-spam".
slugs = {ws.slug for ws in workspaces}
assert slugs == {"alice", "bob-spam"}
_ = u_alice, u_bob # captured for readability
finally:
await _close()
async def test_step1_is_idempotent(tmp_path):
"""Second run is a no-op when every user already has a default_workspace_id."""
sf = await _init_engine(tmp_path)
try:
await _seed_user(sf, email="carol@example.com")
first = await _step1_create_workspaces_for_users(sf, dry_run=False)
assert first == 1
# Re-running picks up the just-populated default_workspace_id, so the
# candidate set is empty.
second = await _step1_create_workspaces_for_users(sf, dry_run=False)
assert second == 0
async with sf() as session:
ws_count = len((await session.execute(select(WorkspaceRow))).scalars().all())
mem_count = len((await session.execute(select(WorkspaceMembershipRow))).scalars().all())
assert ws_count == 1
assert mem_count == 1
finally:
await _close()
async def test_backfill_updates_4_tables_from_users(tmp_path):
"""Step 2 propagates each user's default_workspace_id into 4 business tables."""
sf = await _init_engine(tmp_path)
try:
user_id = await _seed_user(sf, email="dave@example.com")
# Pre-seed business rows owned by the user with NULL workspace_id.
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-1", user_id=user_id)
await _seed_business_row(sf, RunRow, run_id="r-1", thread_id="t-1", user_id=user_id)
await _seed_business_row(sf, FeedbackRow, feedback_id="f-1", thread_id="t-1", run_id="r-1", user_id=user_id, rating=1)
await _seed_business_row(sf, RunEventRow, thread_id="t-1", run_id="r-1", user_id=user_id, event_type="lifecycle_started", category="lifecycle", seq=1)
# Step 1 first so users.default_workspace_id is populated.
await _step1_create_workspaces_for_users(sf, dry_run=False)
async with sf() as session:
ws_id = (await session.execute(select(WorkspaceRow.id))).scalar_one()
# Step 2 updates each table.
for table in ("threads_meta", "runs", "feedback", "run_events"):
count = await _step2_update_table_from_users(sf, table, dry_run=False)
assert count == 1, table
async with sf() as session:
tm = (await session.execute(select(ThreadMetaRow))).scalar_one()
run = (await session.execute(select(RunRow))).scalar_one()
fb = (await session.execute(select(FeedbackRow))).scalar_one()
ev = (await session.execute(select(RunEventRow))).scalar_one()
assert tm.workspace_id == ws_id
assert run.workspace_id == ws_id
assert fb.workspace_id == ws_id
assert ev.workspace_id == ws_id
# Re-running Step 2 is a no-op (filtered by workspace_id IS NULL).
for table in ("threads_meta", "runs", "feedback", "run_events"):
assert await _step2_update_table_from_users(sf, table, dry_run=False) == 0
finally:
await _close()
async def test_backfill_step2_isolates_per_user(tmp_path):
"""Two users with different default workspaces get their own threads tagged independently."""
sf = await _init_engine(tmp_path)
try:
u_eve = await _seed_user(sf, email="eve@example.com")
u_frank = await _seed_user(sf, email="frank@example.com")
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-eve", user_id=u_eve)
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-frank", user_id=u_frank)
await _step1_create_workspaces_for_users(sf, dry_run=False)
await _step2_update_table_from_users(sf, "threads_meta", dry_run=False)
async with sf() as session:
rows = {r.thread_id: r.workspace_id for r in (await session.execute(select(ThreadMetaRow))).scalars().all()}
users = {u.id: u.default_workspace_id for u in (await session.execute(select(UserRow))).scalars().all()}
assert rows["t-eve"] == users[u_eve]
assert rows["t-frank"] == users[u_frank]
assert rows["t-eve"] != rows["t-frank"]
finally:
await _close()
async def test_step1_skips_blacklisted_base_slug(tmp_path):
"""A user with email like admin@... gets bumped past the slug blacklist via the walker."""
sf = await _init_engine(tmp_path)
try:
await _seed_user(sf, email="admin@example.com")
await _step1_create_workspaces_for_users(sf, dry_run=False)
async with sf() as session:
ws = (await session.execute(select(WorkspaceRow))).scalar_one()
# The walker treats "admin" as taken (blacklisted), so it falls
# through to "admin-2" — the same behaviour the registration flow
# uses for reserved slugs.
assert ws.slug == "admin-2"
finally:
await _close()
# ---------------------------------------------------------------------------
# Step 3: orphan rows -> legacy_workspace
# ---------------------------------------------------------------------------
async def test_backfill_orphan_rows_go_to_legacy_workspace(tmp_path):
"""Rows with user_id=NULL get assigned the legacy_workspace UUID after Step 3."""
sf = await _init_engine(tmp_path)
try:
# Seed a platform admin so the legacy workspace has an owner.
await _seed_user(sf, email="admin@example.com", system_role="admin")
# Orphan business rows (user_id=NULL): legacy data from before auth.
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-orphan", user_id=None)
await _seed_business_row(sf, RunRow, run_id="r-orphan", thread_id="t-orphan", user_id=None)
await _seed_business_row(sf, FeedbackRow, feedback_id="f-orphan", thread_id="t-orphan", run_id="r-orphan", user_id=None, rating=1)
await _seed_business_row(sf, RunEventRow, thread_id="t-orphan", run_id="r-orphan", user_id=None, event_type="legacy", category="lifecycle", seq=1)
# Ensure the anchor + reassign per table.
created = await _ensure_legacy_workspace(sf, dry_run=False)
assert created is True
for table in ("threads_meta", "runs", "feedback", "run_events"):
count = await _step3_assign_legacy_workspace(sf, table, dry_run=False)
assert count == 1, table
# Re-running the anchor helper is a no-op.
assert await _ensure_legacy_workspace(sf, dry_run=False) is False
async with sf() as session:
tm = (await session.execute(select(ThreadMetaRow))).scalar_one()
run = (await session.execute(select(RunRow))).scalar_one()
fb = (await session.execute(select(FeedbackRow))).scalar_one()
ev = (await session.execute(select(RunEventRow))).scalar_one()
legacy = (await session.execute(select(WorkspaceRow).where(WorkspaceRow.id == LEGACY_WORKSPACE_ID))).scalar_one()
legacy_mem = (await session.execute(select(WorkspaceMembershipRow).where(WorkspaceMembershipRow.workspace_id == LEGACY_WORKSPACE_ID))).scalar_one()
assert tm.workspace_id == LEGACY_WORKSPACE_ID
assert run.workspace_id == LEGACY_WORKSPACE_ID
assert fb.workspace_id == LEGACY_WORKSPACE_ID
assert ev.workspace_id == LEGACY_WORKSPACE_ID
assert legacy.slug == "legacy"
assert legacy_mem.role == "owner"
finally:
await _close()
async def test_ensure_legacy_workspace_refuses_when_no_users(tmp_path):
"""ensure_legacy_workspace raises a clear error if the DB has no users."""
sf = await _init_engine(tmp_path)
try:
with pytest.raises(RuntimeError, match="no users exist"):
await _ensure_legacy_workspace(sf, dry_run=False)
finally:
await _close()
async def test_backfill_dry_run_does_not_write(tmp_path):
"""``backfill(..., dry_run=True)`` reports counts but writes nothing."""
sf = await _init_engine(tmp_path)
try:
await _seed_user(sf, email="admin@example.com", system_role="admin")
user_id = await _seed_user(sf, email="helen@example.com")
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-owned", user_id=user_id)
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-orphan", user_id=None)
await _seed_business_row(sf, RunRow, run_id="r-owned", thread_id="t-owned", user_id=user_id)
# Snapshot row counts BEFORE the dry run so we can confirm
# nothing changed AFTER.
async with sf() as session:
ws_before = len((await session.execute(select(WorkspaceRow))).scalars().all())
mem_before = len((await session.execute(select(WorkspaceMembershipRow))).scalars().all())
users_with_default_before = len((await session.execute(select(UserRow).where(UserRow.default_workspace_id.is_not(None)))).scalars().all())
report = await backfill(sf, dry_run=True)
assert report["dry_run"] is True
# Step 1 reports 2 candidates (admin + helen, both without default).
assert report["users_workspaces_created"] == 2
# Step 2 reports 0 because Step 1 didn't actually populate
# users.default_workspace_id under dry_run — the JOIN comes up empty.
assert report["threads_meta_from_users"] == 0
assert report["runs_from_users"] == 0
# Step 3 reports the 3 NULL business rows (t-owned, t-orphan, r-owned).
assert report["legacy_workspace_created"] is True
assert report["threads_meta_legacy"] == 2
assert report["runs_legacy"] == 1
# State did not change.
async with sf() as session:
ws_after = len((await session.execute(select(WorkspaceRow))).scalars().all())
mem_after = len((await session.execute(select(WorkspaceMembershipRow))).scalars().all())
users_with_default_after = len((await session.execute(select(UserRow).where(UserRow.default_workspace_id.is_not(None)))).scalars().all())
rows = (await session.execute(select(ThreadMetaRow.workspace_id))).scalars().all()
assert ws_after == ws_before
assert mem_after == mem_before
assert users_with_default_after == users_with_default_before
assert all(w is None for w in rows)
finally:
await _close()
async def test_full_backfill_orchestrator(tmp_path):
"""End-to-end: backfill() runs all three steps and reports per-step counts."""
sf = await _init_engine(tmp_path)
try:
await _seed_user(sf, email="admin@example.com", system_role="admin")
user_id = await _seed_user(sf, email="gina@example.com")
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-owned", user_id=user_id)
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-orphan", user_id=None)
report = await backfill(sf, dry_run=False)
assert report["dry_run"] is False
# Two users were missing a default workspace (admin too — we
# didn't pre-populate admin's default_workspace_id).
assert report["users_workspaces_created"] == 2
assert report["threads_meta_from_users"] == 1
assert report["legacy_workspace_created"] is True
assert report["threads_meta_legacy"] == 1
async with sf() as session:
rows = {r.thread_id: r.workspace_id for r in (await session.execute(select(ThreadMetaRow))).scalars().all()}
assert rows["t-orphan"] == LEGACY_WORKSPACE_ID
assert rows["t-owned"] != LEGACY_WORKSPACE_ID
assert rows["t-owned"] is not None
finally:
await _close()