feat(account): add account settings sync contract
This commit is contained in:
parent
6c0f6dac53
commit
95892d25ec
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.response import error_response, success_response
|
||||
from ...dependencies import get_current_user
|
||||
from ...models import User
|
||||
from ...schemas.account_settings import AccountSettingsUpdateRequest
|
||||
from ...services.account_settings_service import AccountSettingsService
|
||||
|
||||
router = APIRouter(prefix="/account-settings", tags=["account-settings"])
|
||||
|
||||
|
||||
def _service(db: AsyncSession, current_user: User) -> AccountSettingsService:
|
||||
return AccountSettingsService(db, tenant_id=int(current_user.tenant_id))
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_my_account_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
settings = await _service(db, current_user).get_settings(user=current_user)
|
||||
return success_response(settings.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.put("/me")
|
||||
async def update_my_account_settings(
|
||||
body: AccountSettingsUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
service = _service(db, current_user)
|
||||
try:
|
||||
settings = await service.update_settings(user=current_user, body=body)
|
||||
except ValueError as exc:
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
await db.commit()
|
||||
return success_response(settings.model_dump(mode="json"))
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"""Account Settings API schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
AccountLanguage = Literal["zh-CN", "en-US"]
|
||||
AccountAnswerDepth = Literal["concise", "balanced", "deep"]
|
||||
AccountEvidenceDisplay = Literal["summary", "inline", "full"]
|
||||
AccountNotificationKey = Literal[
|
||||
"pendingConfirmation",
|
||||
"executionWriteback",
|
||||
"releaseGate",
|
||||
"failureAlert",
|
||||
]
|
||||
AccountSettingsSaveScope = Literal["local", "account"]
|
||||
|
||||
|
||||
class AccountProfileSettings(BaseModel):
|
||||
displayName: str = Field(min_length=1, max_length=100)
|
||||
roleTitle: str = Field(min_length=1, max_length=100)
|
||||
email: str = Field(default="", max_length=255)
|
||||
defaultLanguage: AccountLanguage = "zh-CN"
|
||||
|
||||
|
||||
class AccountAiPreferenceSettings(BaseModel):
|
||||
answerDepth: AccountAnswerDepth = "balanced"
|
||||
evidenceDisplay: AccountEvidenceDisplay = "inline"
|
||||
|
||||
|
||||
class AccountNotificationPreference(BaseModel):
|
||||
enabled: bool = True
|
||||
desktop: bool = True
|
||||
emailDigest: bool = False
|
||||
|
||||
|
||||
class AccountNotificationSettings(BaseModel):
|
||||
pendingConfirmation: AccountNotificationPreference
|
||||
executionWriteback: AccountNotificationPreference
|
||||
releaseGate: AccountNotificationPreference
|
||||
failureAlert: AccountNotificationPreference
|
||||
|
||||
|
||||
class AccountSettingsUpdateRequest(BaseModel):
|
||||
profile: AccountProfileSettings
|
||||
aiPreferences: AccountAiPreferenceSettings
|
||||
notifications: AccountNotificationSettings
|
||||
|
||||
|
||||
class AccountSettingsResponse(AccountSettingsUpdateRequest):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
saveScope: AccountSettingsSaveScope = "account"
|
||||
updatedAt: str
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AccountAiPreferenceSettings",
|
||||
"AccountAnswerDepth",
|
||||
"AccountEvidenceDisplay",
|
||||
"AccountLanguage",
|
||||
"AccountNotificationKey",
|
||||
"AccountNotificationPreference",
|
||||
"AccountNotificationSettings",
|
||||
"AccountProfileSettings",
|
||||
"AccountSettingsResponse",
|
||||
"AccountSettingsSaveScope",
|
||||
"AccountSettingsUpdateRequest",
|
||||
]
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
"""Account Settings persistence service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import Integration
|
||||
from ..schemas.account_settings import (
|
||||
AccountAiPreferenceSettings,
|
||||
AccountNotificationPreference,
|
||||
AccountNotificationSettings,
|
||||
AccountProfileSettings,
|
||||
AccountSettingsResponse,
|
||||
AccountSettingsUpdateRequest,
|
||||
)
|
||||
from ..utils.timezone import now_cn_naive
|
||||
|
||||
INTEGRATION_TYPE = "account_settings_preferences"
|
||||
|
||||
|
||||
class AccountSettingsService:
|
||||
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
|
||||
self.db = db
|
||||
self.tenant_id = int(tenant_id)
|
||||
|
||||
async def get_settings(self, *, user: Any) -> AccountSettingsResponse:
|
||||
bundle = await self._get_bundle()
|
||||
user_key = self._user_key(user)
|
||||
stored = self._settings_by_user(bundle).get(user_key)
|
||||
if isinstance(stored, dict):
|
||||
return AccountSettingsResponse.model_validate(stored)
|
||||
return self._default_settings(user=user)
|
||||
|
||||
async def update_settings(self, *, user: Any, body: AccountSettingsUpdateRequest) -> AccountSettingsResponse:
|
||||
row, bundle = await self._get_or_init_bundle()
|
||||
user_key = self._user_key(user)
|
||||
settings = AccountSettingsResponse(
|
||||
profile=body.profile,
|
||||
aiPreferences=body.aiPreferences,
|
||||
notifications=body.notifications,
|
||||
saveScope="account",
|
||||
updatedAt=now_cn_naive().isoformat(),
|
||||
)
|
||||
users = self._settings_by_user(bundle)
|
||||
users[user_key] = settings.model_dump(mode="json")
|
||||
bundle["users"] = users
|
||||
bundle["version"] = 1
|
||||
row.config = bundle
|
||||
await self.db.flush()
|
||||
return settings
|
||||
|
||||
async def _get_bundle(self) -> dict[str, Any]:
|
||||
row = (
|
||||
await self.db.execute(
|
||||
select(Integration).where(
|
||||
and_(
|
||||
Integration.tenant_id == self.tenant_id,
|
||||
Integration.type == INTEGRATION_TYPE,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return {"version": 1, "users": {}}
|
||||
return self._normalize_bundle(row.config)
|
||||
|
||||
async def _get_or_init_bundle(self) -> tuple[Integration, dict[str, Any]]:
|
||||
row = (
|
||||
await self.db.execute(
|
||||
select(Integration).where(
|
||||
and_(
|
||||
Integration.tenant_id == self.tenant_id,
|
||||
Integration.type == INTEGRATION_TYPE,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = Integration(
|
||||
tenant_id=self.tenant_id,
|
||||
type=INTEGRATION_TYPE,
|
||||
is_active=True,
|
||||
config={"version": 1, "users": {}},
|
||||
)
|
||||
self.db.add(row)
|
||||
await self.db.flush()
|
||||
bundle = self._normalize_bundle(row.config)
|
||||
if bundle != row.config:
|
||||
row.config = bundle
|
||||
await self.db.flush()
|
||||
return row, bundle
|
||||
|
||||
@staticmethod
|
||||
def _normalize_bundle(config: Any) -> dict[str, Any]:
|
||||
bundle = dict(config or {}) if isinstance(config, dict) else {}
|
||||
if not isinstance(bundle.get("users"), dict):
|
||||
bundle["users"] = {}
|
||||
if not isinstance(bundle.get("version"), int):
|
||||
bundle["version"] = 1
|
||||
return bundle
|
||||
|
||||
@staticmethod
|
||||
def _settings_by_user(bundle: dict[str, Any]) -> dict[str, Any]:
|
||||
users = bundle.get("users")
|
||||
return dict(users) if isinstance(users, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _user_key(user: Any) -> str:
|
||||
return str(getattr(user, "id", "") or "unknown")
|
||||
|
||||
@staticmethod
|
||||
def _default_settings(*, user: Any) -> AccountSettingsResponse:
|
||||
role = str(getattr(user, "role", "") or "").strip()
|
||||
role_title = {
|
||||
"admin": "管理员",
|
||||
"manager": "项目负责人",
|
||||
"ADMIN": "管理员",
|
||||
"MANAGER": "项目负责人",
|
||||
}.get(role, role or "产品负责人")
|
||||
display_name = (
|
||||
str(getattr(user, "name", "") or "").strip()
|
||||
or str(getattr(user, "username", "") or "").strip()
|
||||
or "PMFlow 用户"
|
||||
)
|
||||
email = str(getattr(user, "email", "") or "").strip()
|
||||
return AccountSettingsResponse(
|
||||
profile=AccountProfileSettings(
|
||||
displayName=display_name,
|
||||
roleTitle=role_title,
|
||||
email=email,
|
||||
defaultLanguage="zh-CN",
|
||||
),
|
||||
aiPreferences=AccountAiPreferenceSettings(answerDepth="balanced", evidenceDisplay="inline"),
|
||||
notifications=AccountNotificationSettings(
|
||||
pendingConfirmation=AccountNotificationPreference(enabled=True, desktop=True, emailDigest=False),
|
||||
executionWriteback=AccountNotificationPreference(enabled=True, desktop=True, emailDigest=False),
|
||||
releaseGate=AccountNotificationPreference(enabled=True, desktop=True, emailDigest=True),
|
||||
failureAlert=AccountNotificationPreference(enabled=True, desktop=True, emailDigest=True),
|
||||
),
|
||||
saveScope="account",
|
||||
updatedAt=now_cn_naive().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AccountSettingsService", "INTEGRATION_TYPE"]
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
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",
|
||||
},
|
||||
"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"]["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"]["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"] == "陈雨"
|
||||
|
||||
|
||||
@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
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
import { apiRequest } from "@/api/base";
|
||||
import { getAuthToken } from "@/lib/api";
|
||||
|
||||
export type AccountLanguage = "zh-CN" | "en-US";
|
||||
export type AccountAnswerDepth = "concise" | "balanced" | "deep";
|
||||
export type AccountEvidenceDisplay = "summary" | "inline" | "full";
|
||||
|
|
@ -39,6 +42,13 @@ export interface AccountSettingsSeed {
|
|||
}
|
||||
|
||||
const STORAGE_KEY = "pmflow:account-settings:v1";
|
||||
const ACCOUNT_SETTINGS_ENDPOINT = "/account-settings/me";
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T | null;
|
||||
}
|
||||
|
||||
export const accountNotificationLabels: Record<AccountNotificationKey, { title: string; detail: string }> = {
|
||||
pendingConfirmation: {
|
||||
|
|
@ -93,6 +103,11 @@ function isBrowserStorageAvailable(): boolean {
|
|||
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
}
|
||||
|
||||
function canUseAccountBackend(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return Boolean(getAuthToken());
|
||||
}
|
||||
|
||||
function readStoredAccountSettings(): Partial<AccountSettings> | null {
|
||||
if (!isBrowserStorageAvailable()) return null;
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
|
|
@ -112,6 +127,14 @@ function persistAccountSettings(settings: AccountSettings): void {
|
|||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
function cacheAccountSettingsSnapshot(settings: AccountSettings): void {
|
||||
try {
|
||||
persistAccountSettings(settings);
|
||||
} catch {
|
||||
// Backend account save is the source of truth; local snapshot failure should not downgrade it.
|
||||
}
|
||||
}
|
||||
|
||||
function mergeAccountSettings(base: AccountSettings, stored: Partial<AccountSettings> | null): AccountSettings {
|
||||
if (!stored) return base;
|
||||
return {
|
||||
|
|
@ -134,10 +157,44 @@ function mergeAccountSettings(base: AccountSettings, stored: Partial<AccountSett
|
|||
|
||||
export async function getAccountSettings(seed?: AccountSettingsSeed): Promise<AccountSettings> {
|
||||
const defaults = createDefaultAccountSettings(seed);
|
||||
return mergeAccountSettings(defaults, readStoredAccountSettings());
|
||||
const localSettings = mergeAccountSettings(defaults, readStoredAccountSettings());
|
||||
if (!canUseAccountBackend()) return localSettings;
|
||||
try {
|
||||
const response = await apiRequest<ApiEnvelope<AccountSettings>>(ACCOUNT_SETTINGS_ENDPOINT, { method: "GET" });
|
||||
if (response.data) {
|
||||
const accountSettings = mergeAccountSettings(defaults, response.data);
|
||||
cacheAccountSettingsSnapshot(accountSettings);
|
||||
return accountSettings;
|
||||
}
|
||||
} catch {
|
||||
return localSettings;
|
||||
}
|
||||
return localSettings;
|
||||
}
|
||||
|
||||
export async function updateAccountSettings(nextSettings: AccountSettings): Promise<AccountSettings> {
|
||||
if (canUseAccountBackend()) {
|
||||
try {
|
||||
const response = await apiRequest<ApiEnvelope<AccountSettings>>(ACCOUNT_SETTINGS_ENDPOINT, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
profile: nextSettings.profile,
|
||||
aiPreferences: nextSettings.aiPreferences,
|
||||
notifications: nextSettings.notifications,
|
||||
}),
|
||||
});
|
||||
if (response.data) {
|
||||
const saved = {
|
||||
...response.data,
|
||||
saveScope: "account" as const,
|
||||
};
|
||||
cacheAccountSettingsSnapshot(saved);
|
||||
return saved;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to honest local-only persistence below.
|
||||
}
|
||||
}
|
||||
const persisted = {
|
||||
...nextSettings,
|
||||
saveScope: "local" as const,
|
||||
|
|
|
|||
|
|
@ -3,14 +3,17 @@ import { createDefaultAccountSettings, getAccountSettings, updateAccountSettings
|
|||
|
||||
describe("account settings api", () => {
|
||||
const storageSetItem = Storage.prototype.setItem;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
Storage.prototype.setItem = storageSetItem;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Storage.prototype.setItem = storageSetItem;
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
|
@ -60,4 +63,55 @@ describe("account settings api", () => {
|
|||
|
||||
await expect(updateAccountSettings(createDefaultAccountSettings())).rejects.toThrow("storage denied");
|
||||
});
|
||||
|
||||
it("uses account sync when an authenticated backend contract is available", async () => {
|
||||
window.localStorage.setItem("pmflow_token", "token");
|
||||
const accountSaved = {
|
||||
...createDefaultAccountSettings({ displayName: "陈雨", email: "yu@example.com" }),
|
||||
saveScope: "account" as const,
|
||||
aiPreferences: { answerDepth: "deep" as const, evidenceDisplay: "full" as const },
|
||||
updatedAt: "2026-04-26T10:00:00",
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 0, message: "success", data: accountSaved }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const saved = await updateAccountSettings(accountSaved);
|
||||
|
||||
expect(saved.saveScope).toBe("account");
|
||||
expect(saved.aiPreferences.answerDepth).toBe("deep");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/account-settings/me",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to honest local persistence when backend sync fails", async () => {
|
||||
window.localStorage.setItem("pmflow_token", "token");
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 500, message: "backend unavailable", data: null }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
const defaults = createDefaultAccountSettings({ displayName: "陈雨" });
|
||||
|
||||
const saved = await updateAccountSettings({
|
||||
...defaults,
|
||||
aiPreferences: { answerDepth: "deep", evidenceDisplay: "inline" },
|
||||
});
|
||||
|
||||
expect(saved.saveScope).toBe("local");
|
||||
expect(saved.aiPreferences.answerDepth).toBe("deep");
|
||||
const restored = await getAccountSettings({ displayName: "陈雨" });
|
||||
expect(restored.saveScope).toBe("local");
|
||||
expect(restored.aiPreferences.answerDepth).toBe("deep");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue