from __future__ import annotations from datetime import timedelta import pytest import pytest_asyncio from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy import JSON from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.api import workspaces_api from app.core.database import get_db from app.dependencies.auth import get_current_user, get_optional_current_user from app.models import Project, Tenant, TenantInvitation, TenantMembership, User, UserProject from app.models.base import Base from app.services.workspace_service import hash_invitation_token from app.utils.timezone import now_cn_naive TABLES = [ Tenant.__table__, User.__table__, Project.__table__, UserProject.__table__, TenantMembership.__table__, TenantInvitation.__table__, ] def _use_sqlite_json_types() -> None: for table in TABLES: for column in table.columns: if column.type.__class__.__name__.upper() in {"JSONB", "ARRAY"}: column.type = JSON() @pytest_asyncio.fixture async def workspace_db_env(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") _use_sqlite_json_types() async with engine.begin() as conn: await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=TABLES)) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield session await engine.dispose() def _build_app(session, current_user: User | None = None) -> FastAPI: app = FastAPI() app.include_router(workspaces_api.router) async def _fake_get_db(): yield session app.dependency_overrides[get_db] = _fake_get_db if current_user is not None: app.dependency_overrides[get_current_user] = lambda: current_user app.dependency_overrides[get_optional_current_user] = lambda: current_user return app @pytest.mark.asyncio async def test_invited_user_keeps_personal_workspace_after_leaving_team(workspace_db_env) -> None: session = workspace_db_env team = Tenant(name="Alpha Team", workspace_type="team", status="active") personal = Tenant(name="杨宇 的个人空间", workspace_type="personal", status="active") owner = User( tenant_id=1, username="owner", email="owner@example.com", password_hash="hashed", name="Owner", role="OWNER", assigned_domains=[], workload=0, status="active", is_active=True, ) builder = User( tenant_id=2, username="yangyu", email="yangyu@example.com", password_hash="hashed", name="杨宇", role="OWNER", assigned_domains=[], workload=0, status="active", is_active=True, ) session.add_all([team, personal, owner, builder]) await session.flush() team.owner_user_id = int(owner.id) personal.owner_user_id = int(builder.id) owner.tenant_id = int(team.id) builder.tenant_id = int(personal.id) session.add_all( [ TenantMembership(tenant_id=int(team.id), user_id=int(owner.id), role="OWNER", status="active"), TenantMembership( tenant_id=int(personal.id), user_id=int(builder.id), role="OWNER", status="active", is_personal_owner=True, ), ] ) project = Project( tenant_id=int(team.id), name="Team Project", title="Team Project", description="", owner_id=int(owner.id), status="active", ) session.add(project) await session.flush() token = "invite-token" session.add( TenantInvitation( tenant_id=int(team.id), email="yangyu@example.com", role="BUILDER", project_ids=[int(project.id)], token_hash=hash_invitation_token(token), status="pending", expires_at=now_cn_naive() + timedelta(days=1), invited_by_user_id=int(owner.id), ) ) await session.commit() app = _build_app(session, builder) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: accepted = await client.post("/workspaces/invitations/accept", json={"token": token}) assert accepted.status_code == 200 assert accepted.json()["data"]["workspace_id"] == int(team.id) spaces = await client.get("/workspaces/me") assert spaces.status_code == 200 payload = spaces.json()["data"] assert {item["workspace_type"] for item in payload["workspaces"]} == {"personal", "team"} left = await client.post(f"/workspaces/{team.id}/leave") assert left.status_code == 200 assert left.json()["data"]["active_workspace_id"] == int(personal.id) spaces_after = await client.get("/workspaces/me") assert spaces_after.status_code == 200 workspaces = spaces_after.json()["data"]["workspaces"] assert [item["workspace_type"] for item in workspaces] == ["personal"]