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 auth from app.core.database import get_db from app.models import PhoneVerificationCode, Tenant, TenantInvitation, TenantMembership, User 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__, TenantMembership.__table__, TenantInvitation.__table__, PhoneVerificationCode.__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 phone_auth_session(): 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) -> FastAPI: app = FastAPI() app.include_router(auth.router) async def _fake_get_db(): yield session app.dependency_overrides[get_db] = _fake_get_db return app @pytest.mark.asyncio async def test_phone_code_verify_creates_user_and_personal_workspace(phone_auth_session, monkeypatch) -> None: monkeypatch.setenv("PMFLOW_PHONE_OTP_DEV_CODE", "123456") app = _build_app(phone_auth_session) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: sent = await client.post( "/auth/phone/send-code", json={"phone_country_code": "+86", "phone_number": "13900001001", "purpose": "login"}, ) assert sent.status_code == 200 assert sent.json()["dev_code"] == "123456" verified = await client.post( "/auth/phone/verify", json={ "phone_country_code": "+86", "phone_number": "13900001001", "code": "123456", "purpose": "login", "name": "手机号用户", }, ) assert verified.status_code == 200 body = verified.json() assert body["access_token"] assert body["user"]["phone_e164"] == "+8613900001001" assert body["user"]["workspace_context"]["active_workspace"]["workspace_type"] == "personal" @pytest.mark.asyncio async def test_phone_verify_accepts_bound_invitation_and_keeps_personal_workspace(phone_auth_session, monkeypatch) -> None: monkeypatch.setenv("PMFLOW_PHONE_OTP_DEV_CODE", "654321") session = phone_auth_session team = Tenant(name="手机号团队", workspace_type="team", 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, ) session.add_all([team, owner]) await session.flush() team.owner_user_id = int(owner.id) owner.tenant_id = int(team.id) session.add(TenantMembership(tenant_id=int(team.id), user_id=int(owner.id), role="OWNER", status="active")) token = "phone-invite-token" session.add( TenantInvitation( tenant_id=int(team.id), email="8613900001002@phone.pmflow.local", phone_country_code="+86", phone_number="13900001002", phone_e164="+8613900001002", role="BUILDER", project_ids=[], 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) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: sent = await client.post( "/auth/phone/send-code", json={"phone_country_code": "+86", "phone_number": "13900001002", "purpose": "login"}, ) assert sent.status_code == 200 verified = await client.post( "/auth/phone/verify", json={ "phone_country_code": "+86", "phone_number": "13900001002", "code": "654321", "purpose": "login", "name": "被邀请用户", "invite_token": token, }, ) assert verified.status_code == 200 context = verified.json()["user"]["workspace_context"] assert context["active_workspace"]["id"] == int(team.id) workspace_types = [item["workspace_type"] for item in context["workspaces"]] assert workspace_types.count("personal") == 1 assert workspace_types.count("team") == 1