from __future__ import annotations import os from types import SimpleNamespace import pytest import pytest_asyncio from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy import JSON, select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test") from app.api import account_settings_api # noqa: E402 from app.core.database import get_db # noqa: E402 from app.dependencies.auth import get_current_user # noqa: E402 from app.models.base import Base # noqa: E402 from app.models.integration import Integration # noqa: E402 from app.services.account_settings_service import INTEGRATION_TYPE # noqa: E402 def _use_sqlite_json_types(*tables) -> None: for table in tables: for column in table.columns: if column.type.__class__.__name__ in {"JSONB", "ARRAY", "Vector"}: column.type = JSON() @pytest_asyncio.fixture async def db_session(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") tables = [Integration.__table__] _use_sqlite_json_types(*tables) 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 _current_user(*, user_id: int = 7, tenant_id: int = 1): return SimpleNamespace( id=user_id, tenant_id=tenant_id, role="MEMBER", username=f"tester-{tenant_id}-{user_id}", email=f"tester-{tenant_id}-{user_id}@example.com", name="陈雨", ) def _build_app(db_session, current_user) -> FastAPI: app = FastAPI() app.include_router(account_settings_api.router) async def _fake_get_db(): yield db_session app.dependency_overrides[get_db] = _fake_get_db app.dependency_overrides[get_current_user] = lambda: current_user return app def _settings_payload(**overrides): payload = { "profile": { "displayName": "陈雨", "roleTitle": "独立开发者", "email": "yu@example.com", "defaultLanguage": "en-US", "avatarPreset": "default-avatar-female-02", }, "aiPreferences": { "answerDepth": "deep", "evidenceDisplay": "full", }, "notifications": { "pendingConfirmation": {"enabled": True, "desktop": True, "emailDigest": False}, "executionWriteback": {"enabled": True, "desktop": False, "emailDigest": False}, "releaseGate": {"enabled": True, "desktop": True, "emailDigest": True}, "failureAlert": {"enabled": False, "desktop": False, "emailDigest": False}, }, } payload.update(overrides) return payload @pytest.mark.asyncio async def test_get_account_settings_returns_defaults_from_current_user(db_session) -> None: app = _build_app(db_session, _current_user()) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get("/account-settings/me") assert response.status_code == 200 body = response.json() assert body["code"] == 0 assert body["data"]["profile"]["displayName"] == "陈雨" assert body["data"]["profile"]["defaultLanguage"] == "zh-CN" assert body["data"]["profile"]["avatarPreset"] == "default-avatar-male-01" assert body["data"]["aiPreferences"]["answerDepth"] == "balanced" assert body["data"]["notifications"]["pendingConfirmation"]["enabled"] is True assert body["data"]["saveScope"] == "account" @pytest.mark.asyncio async def test_put_account_settings_persists_user_preferences(db_session) -> None: app = _build_app(db_session, _current_user()) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: update = await client.put("/account-settings/me", json=_settings_payload()) restored = await client.get("/account-settings/me") assert update.status_code == 200 assert update.json()["data"]["saveScope"] == "account" assert restored.status_code == 200 assert restored.json()["data"]["profile"]["defaultLanguage"] == "en-US" assert restored.json()["data"]["profile"]["avatarPreset"] == "default-avatar-female-02" assert restored.json()["data"]["aiPreferences"]["answerDepth"] == "deep" assert restored.json()["data"]["aiPreferences"]["evidenceDisplay"] == "full" assert restored.json()["data"]["notifications"]["failureAlert"]["enabled"] is False row = ( await db_session.execute( select(Integration).where( Integration.tenant_id == 1, Integration.type == INTEGRATION_TYPE, ) ) ).scalar_one() assert row.config["users"]["7"]["profile"]["displayName"] == "陈雨" assert row.config["users"]["7"]["profile"]["avatarPreset"] == "default-avatar-female-02" @pytest.mark.asyncio async def test_account_settings_are_user_scoped(db_session) -> None: user7_app = _build_app(db_session, _current_user(user_id=7)) user8_app = _build_app(db_session, _current_user(user_id=8)) async with AsyncClient(transport=ASGITransport(app=user7_app), base_url="http://test") as client: update = await client.put("/account-settings/me", json=_settings_payload()) async with AsyncClient(transport=ASGITransport(app=user8_app), base_url="http://test") as client: restored = await client.get("/account-settings/me") assert update.status_code == 200 assert restored.status_code == 200 assert restored.json()["data"]["profile"]["defaultLanguage"] == "zh-CN" assert restored.json()["data"]["aiPreferences"]["answerDepth"] == "balanced" @pytest.mark.asyncio async def test_account_settings_reject_invalid_notification_key(db_session) -> None: app = _build_app(db_session, _current_user()) payload = _settings_payload(notifications={"pendingConfirmation": {"enabled": True, "desktop": True, "emailDigest": False}}) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.put("/account-settings/me", json=payload) assert response.status_code == 422