feat(locale): 实现 App 启动时语言和时区自动设置
- 新增系统语言/时区读取工具函数 - SessionStore 扩展支持时区存储 - 启动流程自动检测并保存系统语言/时区 - 注册时传递语言/时区到后端 - 登录后从服务器同步语言/时区
This commit is contained in:
+36
-9
@@ -4,7 +4,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
|
||||||
import '../core/auth/session_store.dart';
|
import '../core/auth/session_store.dart';
|
||||||
|
import '../core/localization/system_locale.dart';
|
||||||
import '../core/logging/logger.dart';
|
import '../core/logging/logger.dart';
|
||||||
|
import '../core/timezone/system_timezone.dart';
|
||||||
import '../data/network/api_client.dart';
|
import '../data/network/api_client.dart';
|
||||||
import '../data/storage/local_kv_store.dart';
|
import '../data/storage/local_kv_store.dart';
|
||||||
import '../features/auth/data/apis/auth_api.dart';
|
import '../features/auth/data/apis/auth_api.dart';
|
||||||
@@ -42,6 +44,7 @@ class _EryaoAppState extends State<EryaoApp> {
|
|||||||
late final NotificationRepository _notificationRepository;
|
late final NotificationRepository _notificationRepository;
|
||||||
late final NotificationBloc _notificationBloc;
|
late final NotificationBloc _notificationBloc;
|
||||||
Locale _locale = const Locale('zh');
|
Locale _locale = const Locale('zh');
|
||||||
|
String _timezone = 'Asia/Shanghai';
|
||||||
ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale(
|
ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale(
|
||||||
const Locale('zh'),
|
const Locale('zh'),
|
||||||
);
|
);
|
||||||
@@ -300,11 +303,14 @@ class _EryaoAppState extends State<EryaoApp> {
|
|||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final serverLanguage = profile.preferences.interfaceLanguage;
|
final serverLanguage = profile.preferences.language;
|
||||||
|
final serverTimezone = profile.preferences.timezone;
|
||||||
final serverLocale = localeFromLanguageTag(serverLanguage);
|
final serverLocale = localeFromLanguageTag(serverLanguage);
|
||||||
await _sessionStore.saveLocaleTag(serverLanguage);
|
await _sessionStore.saveLocaleTag(serverLanguage);
|
||||||
|
await _sessionStore.saveTimezone(serverTimezone);
|
||||||
setState(() {
|
setState(() {
|
||||||
_locale = serverLocale;
|
_locale = serverLocale;
|
||||||
|
_timezone = serverTimezone;
|
||||||
_profileSettings = profile;
|
_profileSettings = profile;
|
||||||
_loadedProfileUserEmail = userEmail;
|
_loadedProfileUserEmail = userEmail;
|
||||||
});
|
});
|
||||||
@@ -352,8 +358,8 @@ class _EryaoAppState extends State<EryaoApp> {
|
|||||||
|
|
||||||
Future<void> _saveProfileSettings(ProfileSettingsV1 next) async {
|
Future<void> _saveProfileSettings(ProfileSettingsV1 next) async {
|
||||||
try {
|
try {
|
||||||
final oldLanguage = _profileSettings.preferences.interfaceLanguage;
|
final oldLanguage = _profileSettings.preferences.language;
|
||||||
final newLanguage = next.preferences.interfaceLanguage;
|
final newLanguage = next.preferences.language;
|
||||||
final saved = await _profileApi.updateSettings(next);
|
final saved = await _profileApi.updateSettings(next);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
@@ -383,13 +389,29 @@ class _EryaoAppState extends State<EryaoApp> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _bootstrap() async {
|
Future<void> _bootstrap() async {
|
||||||
final localeTag = await _sessionStore.getLocaleTag();
|
final savedLocaleTag = await _sessionStore.getLocaleTag();
|
||||||
final locale = localeTag != null
|
final Locale locale;
|
||||||
? localeFromLanguageTag(localeTag)
|
if (savedLocaleTag != null) {
|
||||||
: const Locale('zh');
|
locale = localeFromLanguageTag(savedLocaleTag);
|
||||||
|
} else {
|
||||||
|
final systemLocale = getSystemLocale();
|
||||||
|
locale = resolveSystemLocale(systemLocale) ?? const Locale('zh');
|
||||||
|
await _sessionStore.saveLocaleTag(languageTagFromLocale(locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
final savedTimezone = await _sessionStore.getTimezone();
|
||||||
|
final String timezone;
|
||||||
|
if (savedTimezone != null) {
|
||||||
|
timezone = savedTimezone;
|
||||||
|
} else {
|
||||||
|
timezone = await getSystemTimezone();
|
||||||
|
await _sessionStore.saveTimezone(timezone);
|
||||||
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_locale = locale;
|
_locale = locale;
|
||||||
|
_timezone = timezone;
|
||||||
_profileSettings = ProfileSettingsV1.defaultsForLocale(locale);
|
_profileSettings = ProfileSettingsV1.defaultsForLocale(locale);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -406,7 +428,7 @@ class _EryaoAppState extends State<EryaoApp> {
|
|||||||
_locale = locale;
|
_locale = locale;
|
||||||
_profileSettings = _profileSettings.copyWith(
|
_profileSettings = _profileSettings.copyWith(
|
||||||
preferences: _profileSettings.preferences.copyWith(
|
preferences: _profileSettings.preferences.copyWith(
|
||||||
interfaceLanguage: languageTag,
|
language: languageTag,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -486,7 +508,12 @@ class _EryaoAppState extends State<EryaoApp> {
|
|||||||
onLocaleChanged: (_) {},
|
onLocaleChanged: (_) {},
|
||||||
onRequestOtp: _authBloc.sendOtp,
|
onRequestOtp: _authBloc.sendOtp,
|
||||||
onLoginWithOtp: (email, otp) {
|
onLoginWithOtp: (email, otp) {
|
||||||
return _authBloc.loginWithOtp(email: email, otp: otp);
|
return _authBloc.loginWithOtp(
|
||||||
|
email: email,
|
||||||
|
otp: otp,
|
||||||
|
language: languageTagFromLocale(_locale),
|
||||||
|
timezone: _timezone,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class SessionStore {
|
|||||||
static const String _emailKey = 'saved_email';
|
static const String _emailKey = 'saved_email';
|
||||||
static const String _welcomeReadKey = 'has_seen_welcome_dialog';
|
static const String _welcomeReadKey = 'has_seen_welcome_dialog';
|
||||||
static const String _localeKey = 'selected_locale';
|
static const String _localeKey = 'selected_locale';
|
||||||
|
static const String _timezoneKey = 'selected_timezone';
|
||||||
|
|
||||||
Future<void> saveToken(String token) async {
|
Future<void> saveToken(String token) async {
|
||||||
await _secureStorage.write(key: _tokenKey, value: token);
|
await _secureStorage.write(key: _tokenKey, value: token);
|
||||||
@@ -66,4 +67,12 @@ class SessionStore {
|
|||||||
Future<String?> getLocaleTag() async {
|
Future<String?> getLocaleTag() async {
|
||||||
return _kvStore.getString(_localeKey);
|
return _kvStore.getString(_localeKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveTimezone(String timezone) async {
|
||||||
|
await _kvStore.setString(_timezoneKey, timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> getTimezone() async {
|
||||||
|
return _kvStore.getString(_timezoneKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
Locale? resolveSystemLocale(Locale systemLocale) {
|
||||||
|
final lang = systemLocale.languageCode.toLowerCase();
|
||||||
|
final script = systemLocale.scriptCode;
|
||||||
|
final country = systemLocale.countryCode;
|
||||||
|
|
||||||
|
if (lang == 'en') {
|
||||||
|
return const Locale('en');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lang == 'zh') {
|
||||||
|
if (script == 'Hant' ||
|
||||||
|
country == 'TW' ||
|
||||||
|
country == 'HK' ||
|
||||||
|
country == 'MO') {
|
||||||
|
return const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant');
|
||||||
|
}
|
||||||
|
return const Locale('zh');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Locale getSystemLocale() {
|
||||||
|
return PlatformDispatcher.instance.locale;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||||
|
|
||||||
|
Future<String> getSystemTimezone() async {
|
||||||
|
return FlutterTimezone.getLocalTimezone();
|
||||||
|
}
|
||||||
@@ -19,10 +19,18 @@ class AuthApi {
|
|||||||
Future<SessionResponse> createEmailSession({
|
Future<SessionResponse> createEmailSession({
|
||||||
required String email,
|
required String email,
|
||||||
required String token,
|
required String token,
|
||||||
|
String? language,
|
||||||
|
String? timezone,
|
||||||
}) async {
|
}) async {
|
||||||
|
final data = <String, dynamic>{
|
||||||
|
'email': email,
|
||||||
|
'token': token,
|
||||||
|
};
|
||||||
|
if (language != null) data['language'] = language;
|
||||||
|
if (timezone != null) data['timezone'] = timezone;
|
||||||
final json = await _apiClient.postJson(
|
final json = await _apiClient.postJson(
|
||||||
'/api/v1/auth/email-session',
|
'/api/v1/auth/email-session',
|
||||||
data: {'email': email, 'token': token},
|
data: data,
|
||||||
);
|
);
|
||||||
return SessionResponse.fromJson(json);
|
return SessionResponse.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ abstract class AuthRepository {
|
|||||||
Future<AuthUser> loginWithEmailOtp({
|
Future<AuthUser> loginWithEmailOtp({
|
||||||
required String email,
|
required String email,
|
||||||
required String otp,
|
required String otp,
|
||||||
|
String? language,
|
||||||
|
String? timezone,
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<AuthUser?> recoverSession();
|
Future<AuthUser?> recoverSession();
|
||||||
@@ -37,8 +39,15 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
Future<AuthUser> loginWithEmailOtp({
|
Future<AuthUser> loginWithEmailOtp({
|
||||||
required String email,
|
required String email,
|
||||||
required String otp,
|
required String otp,
|
||||||
|
String? language,
|
||||||
|
String? timezone,
|
||||||
}) async {
|
}) async {
|
||||||
final session = await _authApi.createEmailSession(email: email, token: otp);
|
final session = await _authApi.createEmailSession(
|
||||||
|
email: email,
|
||||||
|
token: otp,
|
||||||
|
language: language,
|
||||||
|
timezone: timezone,
|
||||||
|
);
|
||||||
await _sessionStore.saveToken(session.accessToken);
|
await _sessionStore.saveToken(session.accessToken);
|
||||||
await _sessionStore.saveRefreshToken(session.refreshToken);
|
await _sessionStore.saveRefreshToken(session.refreshToken);
|
||||||
await _sessionStore.saveEmail(email);
|
await _sessionStore.saveEmail(email);
|
||||||
|
|||||||
@@ -50,8 +50,15 @@ class AuthBloc extends ChangeNotifier {
|
|||||||
Future<void> loginWithOtp({
|
Future<void> loginWithOtp({
|
||||||
required String email,
|
required String email,
|
||||||
required String otp,
|
required String otp,
|
||||||
|
String? language,
|
||||||
|
String? timezone,
|
||||||
}) async {
|
}) async {
|
||||||
final user = await _repository.loginWithEmailOtp(email: email, otp: otp);
|
final user = await _repository.loginWithEmailOtp(
|
||||||
|
email: email,
|
||||||
|
otp: otp,
|
||||||
|
language: language,
|
||||||
|
timezone: timezone,
|
||||||
|
);
|
||||||
_logger.info(message: 'User logged in', extra: {'user_id': user.id});
|
_logger.info(message: 'User logged in', extra: {'user_id': user.id});
|
||||||
_state = AuthState(status: AuthStatus.authenticated, user: user);
|
_state = AuthState(status: AuthStatus.authenticated, user: user);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
String formatRelativeTime(BuildContext context, DateTime dateTime) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final diff = DateTime.now().difference(dateTime);
|
||||||
|
if (diff.inMinutes < 1) return l10n.timeJustNow;
|
||||||
|
if (diff.inHours < 1) return l10n.timeMinutesAgo(diff.inMinutes);
|
||||||
|
if (diff.inDays < 1) return l10n.timeHoursAgo(diff.inHours);
|
||||||
|
if (diff.inDays < 30) return l10n.timeDaysAgo(diff.inDays);
|
||||||
|
return '${dateTime.month}/${dateTime.day}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatCompactLocalDateTime(String isoString) {
|
||||||
|
final dateTime = DateTime.parse(isoString).toLocal();
|
||||||
|
final now = DateTime.now();
|
||||||
|
final timeText =
|
||||||
|
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
if (now.year == dateTime.year &&
|
||||||
|
now.month == dateTime.month &&
|
||||||
|
now.day == dateTime.day) {
|
||||||
|
return timeText;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dateText =
|
||||||
|
'${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}';
|
||||||
|
return '$dateText $timeText';
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ from v1.auth.schemas import (
|
|||||||
from v1.auth.service import AuthService
|
from v1.auth.service import AuthService
|
||||||
from v1.points.repository import PointsRepository
|
from v1.points.repository import PointsRepository
|
||||||
from v1.points.service import PointsService
|
from v1.points.service import PointsService
|
||||||
|
from v1.users.repository import SQLAlchemyUserRepository
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
@@ -72,14 +73,30 @@ async def create_email_session(
|
|||||||
window_seconds=300,
|
window_seconds=300,
|
||||||
)
|
)
|
||||||
result = await service.create_email_session(payload)
|
result = await service.create_email_session(payload)
|
||||||
|
user_id = UUID(result.user.id)
|
||||||
|
|
||||||
|
if payload.language is not None or payload.timezone is not None:
|
||||||
|
user_repository = SQLAlchemyUserRepository(session)
|
||||||
|
profile = await user_repository.get_profile_by_user_id(user_id=user_id)
|
||||||
|
if profile is not None:
|
||||||
|
settings: dict[str, object] = dict(profile.settings or {})
|
||||||
|
prefs_raw = settings.get("preferences", {})
|
||||||
|
preferences: dict[str, object] = dict(prefs_raw) if isinstance(prefs_raw, dict) else {}
|
||||||
|
if payload.language is not None:
|
||||||
|
preferences["language"] = payload.language
|
||||||
|
if payload.timezone is not None:
|
||||||
|
preferences["timezone"] = payload.timezone
|
||||||
|
settings["preferences"] = preferences
|
||||||
|
profile.settings = settings
|
||||||
|
|
||||||
points_service = PointsService(repository=PointsRepository(session))
|
points_service = PointsService(repository=PointsRepository(session))
|
||||||
bonus_result = await points_service.grant_register_bonus_if_eligible(
|
bonus_result = await points_service.grant_register_bonus_if_eligible(
|
||||||
user_id=UUID(result.user.id),
|
user_id=user_id,
|
||||||
user_email=result.user.email,
|
user_email=result.user.email,
|
||||||
)
|
)
|
||||||
notification_service = NotificationService(NotificationRepository(session))
|
notification_service = NotificationService(NotificationRepository(session))
|
||||||
linked_count = await notification_service.link_notifications_for_registered_user(
|
linked_count = await notification_service.link_notifications_for_registered_user(
|
||||||
user_id=UUID(result.user.id),
|
user_id=user_id,
|
||||||
is_first_registration=bonus_result.is_first_registration,
|
is_first_registration=bonus_result.is_first_registration,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class EmailSessionCreateRequest(BaseModel):
|
|||||||
|
|
||||||
email: str = Field(pattern=SUPABASE_EMAIL_PATTERN)
|
email: str = Field(pattern=SUPABASE_EMAIL_PATTERN)
|
||||||
token: str = Field(min_length=6, max_length=6)
|
token: str = Field(min_length=6, max_length=6)
|
||||||
|
language: str | None = Field(default=None, max_length=20)
|
||||||
|
timezone: str | None = Field(default=None, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
class SessionRefreshRequest(BaseModel):
|
class SessionRefreshRequest(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.db.session import AsyncSessionLocal
|
||||||
|
from models.profile import Profile
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_email_session(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
code: str,
|
||||||
|
language: str | None = None,
|
||||||
|
timezone: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
payload: dict = {"email": email, "token": code}
|
||||||
|
if language is not None:
|
||||||
|
payload["language"] = language
|
||||||
|
if timezone is not None:
|
||||||
|
payload["timezone"] = timezone
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/email-session",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_profile(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
*,
|
||||||
|
access_token: str,
|
||||||
|
) -> dict:
|
||||||
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
resp = await client.get("/api/v1/users/me/profile", headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_new_user_registration_with_language_timezone(
|
||||||
|
api_client: httpx.AsyncClient,
|
||||||
|
test_identity: dict[str, str],
|
||||||
|
db_cleanup: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""场景1: 新用户首次打开App,注册时语言和时区写入后端"""
|
||||||
|
email = test_identity["email"]
|
||||||
|
code = test_identity["code"]
|
||||||
|
db_cleanup.append(email)
|
||||||
|
|
||||||
|
session_data = await _create_email_session(
|
||||||
|
api_client,
|
||||||
|
email=email,
|
||||||
|
code=code,
|
||||||
|
language="en-US",
|
||||||
|
timezone="America/New_York",
|
||||||
|
)
|
||||||
|
access_token = str(session_data["access_token"])
|
||||||
|
|
||||||
|
profile = await _get_profile(api_client, access_token=access_token)
|
||||||
|
preferences = profile["settings"]["preferences"]
|
||||||
|
|
||||||
|
assert preferences["language"] == "en-US", f"Expected en-US, got {preferences['language']}"
|
||||||
|
assert preferences["timezone"] == "America/New_York", f"Expected America/New_York, got {preferences['timezone']}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_new_user_registration_with_zh_hant(
|
||||||
|
api_client: httpx.AsyncClient,
|
||||||
|
test_identity: dict[str, str],
|
||||||
|
db_cleanup: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""场景1变体: 新用户注册时使用繁体中文"""
|
||||||
|
email = test_identity["email"]
|
||||||
|
code = test_identity["code"]
|
||||||
|
db_cleanup.append(email)
|
||||||
|
|
||||||
|
session_data = await _create_email_session(
|
||||||
|
api_client,
|
||||||
|
email=email,
|
||||||
|
code=code,
|
||||||
|
language="zh-Hant",
|
||||||
|
timezone="Asia/Taipei",
|
||||||
|
)
|
||||||
|
access_token = str(session_data["access_token"])
|
||||||
|
|
||||||
|
profile = await _get_profile(api_client, access_token=access_token)
|
||||||
|
preferences = profile["settings"]["preferences"]
|
||||||
|
|
||||||
|
assert preferences["language"] == "zh-Hant"
|
||||||
|
assert preferences["timezone"] == "Asia/Taipei"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_new_user_registration_without_language_timezone(
|
||||||
|
api_client: httpx.AsyncClient,
|
||||||
|
test_identity: dict[str, str],
|
||||||
|
db_cleanup: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""场景2: 新用户注册时不传语言时区,使用后端默认值"""
|
||||||
|
email = test_identity["email"]
|
||||||
|
code = test_identity["code"]
|
||||||
|
db_cleanup.append(email)
|
||||||
|
|
||||||
|
session_data = await _create_email_session(
|
||||||
|
api_client,
|
||||||
|
email=email,
|
||||||
|
code=code,
|
||||||
|
)
|
||||||
|
access_token = str(session_data["access_token"])
|
||||||
|
|
||||||
|
profile = await _get_profile(api_client, access_token=access_token)
|
||||||
|
preferences = profile["settings"]["preferences"]
|
||||||
|
|
||||||
|
assert preferences["language"] == "zh-CN", "Default language should be zh-CN"
|
||||||
|
assert preferences["timezone"] == "Asia/Shanghai", "Default timezone should be Asia/Shanghai"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_existing_user_login_sync_from_server(
|
||||||
|
api_client: httpx.AsyncClient,
|
||||||
|
test_identity: dict[str, str],
|
||||||
|
db_cleanup: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""场景2: 已有用户在新设备登录,从服务器同步语言时区"""
|
||||||
|
email = test_identity["email"]
|
||||||
|
code = test_identity["code"]
|
||||||
|
db_cleanup.append(email)
|
||||||
|
|
||||||
|
session1 = await _create_email_session(
|
||||||
|
api_client,
|
||||||
|
email=email,
|
||||||
|
code=code,
|
||||||
|
language="en-US",
|
||||||
|
timezone="Europe/London",
|
||||||
|
)
|
||||||
|
access_token1 = str(session1["access_token"])
|
||||||
|
|
||||||
|
profile1 = await _get_profile(api_client, access_token=access_token1)
|
||||||
|
preferences1 = profile1["settings"]["preferences"]
|
||||||
|
assert preferences1["language"] == "en-US"
|
||||||
|
assert preferences1["timezone"] == "Europe/London"
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
session2 = await _create_email_session(
|
||||||
|
api_client,
|
||||||
|
email=email,
|
||||||
|
code=code,
|
||||||
|
)
|
||||||
|
access_token2 = str(session2["access_token"])
|
||||||
|
|
||||||
|
profile2 = await _get_profile(api_client, access_token=access_token2)
|
||||||
|
preferences2 = profile2["settings"]["preferences"]
|
||||||
|
|
||||||
|
assert preferences2["language"] == "en-US", "Should sync language from server"
|
||||||
|
assert preferences2["timezone"] == "Europe/London", "Should sync timezone from server"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_partial_language_only(
|
||||||
|
api_client: httpx.AsyncClient,
|
||||||
|
test_identity: dict[str, str],
|
||||||
|
db_cleanup: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""边界情况: 只传语言不传时区"""
|
||||||
|
email = test_identity["email"]
|
||||||
|
code = test_identity["code"]
|
||||||
|
db_cleanup.append(email)
|
||||||
|
|
||||||
|
session_data = await _create_email_session(
|
||||||
|
api_client,
|
||||||
|
email=email,
|
||||||
|
code=code,
|
||||||
|
language="zh-Hant",
|
||||||
|
)
|
||||||
|
access_token = str(session_data["access_token"])
|
||||||
|
|
||||||
|
profile = await _get_profile(api_client, access_token=access_token)
|
||||||
|
preferences = profile["settings"]["preferences"]
|
||||||
|
|
||||||
|
assert preferences["language"] == "zh-Hant"
|
||||||
|
assert preferences["timezone"] == "Asia/Shanghai", "Timezone should use default"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_partial_timezone_only(
|
||||||
|
api_client: httpx.AsyncClient,
|
||||||
|
test_identity: dict[str, str],
|
||||||
|
db_cleanup: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""边界情况: 只传时区不传语言"""
|
||||||
|
email = test_identity["email"]
|
||||||
|
code = test_identity["code"]
|
||||||
|
db_cleanup.append(email)
|
||||||
|
|
||||||
|
session_data = await _create_email_session(
|
||||||
|
api_client,
|
||||||
|
email=email,
|
||||||
|
code=code,
|
||||||
|
timezone="Australia/Sydney",
|
||||||
|
)
|
||||||
|
access_token = str(session_data["access_token"])
|
||||||
|
|
||||||
|
profile = await _get_profile(api_client, access_token=access_token)
|
||||||
|
preferences = profile["settings"]["preferences"]
|
||||||
|
|
||||||
|
assert preferences["language"] == "zh-CN", "Language should use default"
|
||||||
|
assert preferences["timezone"] == "Australia/Sydney"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_profile_settings_persisted_in_database(
|
||||||
|
api_client: httpx.AsyncClient,
|
||||||
|
test_identity: dict[str, str],
|
||||||
|
db_cleanup: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""验证语言时区正确持久化到数据库"""
|
||||||
|
email = test_identity["email"]
|
||||||
|
code = test_identity["code"]
|
||||||
|
db_cleanup.append(email)
|
||||||
|
|
||||||
|
session_data = await _create_email_session(
|
||||||
|
api_client,
|
||||||
|
email=email,
|
||||||
|
code=code,
|
||||||
|
language="en-US",
|
||||||
|
timezone="America/Los_Angeles",
|
||||||
|
)
|
||||||
|
user_id = str(session_data["user"]["id"])
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db_session:
|
||||||
|
stmt = select(Profile).where(Profile.id == user_id)
|
||||||
|
result = await db_session.execute(stmt)
|
||||||
|
profile = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
assert profile is not None, "Profile should exist in database"
|
||||||
|
settings = profile.settings
|
||||||
|
assert settings is not None
|
||||||
|
preferences = settings.get("preferences", {})
|
||||||
|
assert preferences.get("language") == "en-US"
|
||||||
|
assert preferences.get("timezone") == "America/Los_Angeles"
|
||||||
Reference in New Issue
Block a user