From 8be21b856b0d5d50ff54eb37e35476e1bf23d53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Sat, 19 Sep 2026 01:19:57 +0800 Subject: [PATCH] feat(mobile): login, password change and logout screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3 Task 3 移动端(对接 Task 2 api client + 本任务新增的后端契约): - 登录页实装:账号/密码表单(空值客户端校验)→ api.login(会话自动 持久化)→ pushReplacement /home;错误码本地化(AUTH_INVALID_ CREDENTIALS / AUTH_RATE_LIMITED(含稍后再试文案)/ 网络 / 校验); 提交加载态防重复提交。 - 修改密码页(设置页入口):当前/新/确认三字段,客户端校验(≥8 位、 两次一致)→ api.changePassword → toast + pop;成功后本地会话不变 (服务端保留当前会话);旧密码错误(401 AUTH_INVALID_CREDENTIALS) 显示「当前密码错误」且不触发 401 驱逐。 - 退出登录:设置页按钮 → api.logout 尽力而为 → 清会话 → 清栈回 /login;App 壳 initState 注册 onUnauthorized(业务 401 → 清栈 /login), 根 navigatorKey 接线;password-change 的 401 豁免会话驱逐(拦截器 豁免集合与令牌附加分离)。 - l10n:新增 16 组 zh/en 文案;错误码 → 文案解析扩展 (core/error_l10n.dart,errorCode→AppLocalizations)。 - 测试:ApiClient.changePassword 单测 ×2(Bearer+请求体;旧密码 401 不驱逐);widget 测试 ×12(登录 5:空值/成功跳转/凭据错误/限速文案/ 加载禁用;改密 4:过短/不一致/旧密码错误文案+会话保留/成功 toast+pop 且令牌不变;设置 2:改密入口跳转/登出清会话回登录;App 壳 1:401 清栈回 /login)。flutter analyze 0 issue;flutter test 81/81。 Co-Authored-By: Claude Code --- apps/mobile/lib/app/app.dart | 34 +++- apps/mobile/lib/core/api_client.dart | 30 ++- apps/mobile/lib/core/error_l10n.dart | 42 +++++ apps/mobile/lib/l10n/app_en.arb | 18 +- apps/mobile/lib/l10n/app_localizations.dart | 96 ++++++++++ .../mobile/lib/l10n/app_localizations_en.dart | 50 +++++ .../mobile/lib/l10n/app_localizations_zh.dart | 48 +++++ apps/mobile/lib/l10n/app_zh.arb | 18 +- apps/mobile/lib/pages/home_page.dart | 25 ++- apps/mobile/lib/pages/login_page.dart | 143 ++++++++++---- .../lib/pages/password_change_page.dart | 177 ++++++++++++++++++ apps/mobile/test/app/app_shell_test.dart | 57 ++++++ apps/mobile/test/core/api_client_test.dart | 45 +++++ apps/mobile/test/helpers/fakes.dart | 100 ++++++++++ apps/mobile/test/pages/login_page_test.dart | 155 +++++++++++++++ .../test/pages/password_change_page_test.dart | 164 ++++++++++++++++ apps/mobile/test/pages/settings_tab_test.dart | 88 +++++++++ 17 files changed, 1239 insertions(+), 51 deletions(-) create mode 100644 apps/mobile/lib/core/error_l10n.dart create mode 100644 apps/mobile/lib/pages/password_change_page.dart create mode 100644 apps/mobile/test/app/app_shell_test.dart create mode 100644 apps/mobile/test/helpers/fakes.dart create mode 100644 apps/mobile/test/pages/login_page_test.dart create mode 100644 apps/mobile/test/pages/password_change_page_test.dart create mode 100644 apps/mobile/test/pages/settings_tab_test.dart diff --git a/apps/mobile/lib/app/app.dart b/apps/mobile/lib/app/app.dart index 49103f6..0ae07a6 100644 --- a/apps/mobile/lib/app/app.dart +++ b/apps/mobile/lib/app/app.dart @@ -1,23 +1,46 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api_client.dart'; import '../l10n/app_localizations.dart'; import '../pages/home_page.dart'; import '../pages/login_page.dart'; +import '../pages/password_change_page.dart'; import 'locale_controller.dart'; import 'theme.dart'; -/// App 根组件:主题 + l10n 委托 + 基线路由。 +/// 根导航键:[ApiClient.onUnauthorized](非 UI 上下文触发)跳转 /login 用。 +final GlobalKey rootNavigatorKey = + GlobalKey(); + +/// App 根组件:主题 + l10n 委托 + 命名路由 + 401 驱逐接线。 /// -/// 基线路由为命名路由(/login → /home);Task 3+ 随页面增长再评估 -/// 是否迁移 go_router,当前保持零额外依赖。 -class PhotoLiveApp extends ConsumerWidget { +/// 基线路由为命名路由(/login → /home → /password-change);随页面增长再 +/// 评估是否迁移 go_router,当前保持零额外依赖。 +/// initState 注册 [ApiClient.onUnauthorized]:任意业务端点 401(会话过期) +/// 即清空栈回到 /login(登录/修改密码端点自身的 401 不触发,见 ApiClient)。 +class PhotoLiveApp extends ConsumerStatefulWidget { const PhotoLiveApp({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _PhotoLiveAppState(); +} + +class _PhotoLiveAppState extends ConsumerState { + @override + void initState() { + super.initState(); + ref.read(apiClientProvider).onUnauthorized = () { + rootNavigatorKey.currentState + ?.pushNamedAndRemoveUntil('/login', (route) => false); + }; + } + + @override + Widget build(BuildContext context) { final locale = ref.watch(localeSettingsProvider); return MaterialApp( + navigatorKey: rootNavigatorKey, onGenerateTitle: (context) => AppLocalizations.of(context).appName, theme: AppTheme.light, darkTheme: AppTheme.dark, @@ -27,6 +50,7 @@ class PhotoLiveApp extends ConsumerWidget { routes: { '/login': (context) => const LoginPage(), '/home': (context) => const HomePage(), + '/password-change': (context) => const PasswordChangePage(), }, home: const LoginPage(), ); diff --git a/apps/mobile/lib/core/api_client.dart b/apps/mobile/lib/core/api_client.dart index e660305..d2649e3 100644 --- a/apps/mobile/lib/core/api_client.dart +++ b/apps/mobile/lib/core/api_client.dart @@ -4,7 +4,8 @@ /// - Bearer 注入:请求拦截器从 [SessionStore] 读取令牌(登录端点豁免)。 /// - 401 处理:清空会话并触发 [onUnauthorized](App 壳注册的监听回调, /// 负责跳转 /login);并发的后续 401 只通知一次,直至新会话写入。 -/// 登录端点自身的 401(凭据错误)不触发会话驱逐。 +/// 登录端点自身的 401(凭据错误)与修改密码端点的 401(旧密码错误) +/// 不是会话过期语义,不触发会话驱逐。 /// - 错误归一:HTTP 错误体 `{code, message, requestId}` 与网络层失败一律 /// 抛 [AppException](码表见 [ErrorCodes];l10n 文案解析留在 UI 层)。 library; @@ -64,10 +65,15 @@ class ApiClient { void close({bool force = false}) => _dio.close(force: force); - /// 登录端点:401 = 凭据错误而非会话过期,不驱逐会话、不附加令牌。 - static bool _isAuthExemptPath(String path) => + /// 登录端点:不附加令牌(未认证端点)。 + static bool _isLoginPath(String path) => path.endsWith('/v1/auth/password'); + /// 401 驱逐豁免:登录(凭据错误)与修改密码(旧密码错误)的 401 都不是 + /// 会话过期语义,不得清会话/触发跳登录;其余端点 401 一律驱逐。 + static bool _evictsSessionOn401(String path) => + !_isLoginPath(path) && !path.endsWith('/v1/auth/password-change'); + // ── 拦截器 ── Future _attachSession( @@ -75,7 +81,7 @@ class ApiClient { RequestInterceptorHandler handler, ) async { try { - if (!_isAuthExemptPath(options.path)) { + if (!_isLoginPath(options.path)) { final session = await _session.read(); if (session != null && session.token.isNotEmpty && @@ -95,7 +101,7 @@ class ApiClient { ErrorInterceptorHandler handler, ) async { if (e.response?.statusCode == 401 && - !_isAuthExemptPath(e.requestOptions.path)) { + _evictsSessionOn401(e.requestOptions.path)) { try { // 先同步置位去重标记再做异步清除:并发 401 下仅首个触发通知。 final shouldNotify = !_unauthorizedNotified; @@ -256,6 +262,20 @@ class ApiClient { } } + /// operationId `changePassword`(POST /auth/password-change → 204, + /// P3 Task 3 契约增补)。 + /// + /// 服务端成功后撤销其他会话、**保留当前会话**——本地令牌继续有效, + /// 无需重登录。旧密码错误 → 401 AUTH_INVALID_CREDENTIALS(不驱逐会话, + /// 见 [_evictsSessionOn401])。 + Future changePassword(String oldPassword, String newPassword) => + _send( + () => _dio.post( + '/v1/auth/password-change', + data: {'oldPassword': oldPassword, 'newPassword': newPassword}, + ), + ); + // ── albums / members / categories / admin ── /// operationId `listAlbums`(GET /albums,游标分页)。 diff --git a/apps/mobile/lib/core/error_l10n.dart b/apps/mobile/lib/core/error_l10n.dart new file mode 100644 index 0000000..174a3d5 --- /dev/null +++ b/apps/mobile/lib/core/error_l10n.dart @@ -0,0 +1,42 @@ +/// 错误码 → 本地化文案解析(P3 计划 Task 3)。 +/// +/// 契约约定(openapi.yaml info.description):客户端按稳定 `code` 映射 +/// 多语言文案,`message` 仅供诊断。本扩展覆盖 UI 常见展示的契约码与客户端 +/// 伪码([ErrorCodes]);未映射的码回退通用文案(errorGeneric)。 +/// +/// 页面可对特定码做更贴切的覆写后再回退到本方法(例:修改密码页把 +/// AUTH_INVALID_CREDENTIALS 显示为「当前密码错误」而非「账号或密码错误」)。 +library; + +import '../l10n/app_localizations.dart'; +import 'app_exception.dart'; +import 'error_codes.dart'; + +extension AppLocalizationsErrorX on AppLocalizations { + /// 按稳定错误码解析展示文案。 + String messageForErrorCode(String code) { + switch (code) { + case ErrorCodes.authInvalidCredentials: + return errorAuthInvalidCredentials; + case ErrorCodes.authRateLimited: + case ErrorCodes.rateLimited: + return errorAuthRateLimited; + case ErrorCodes.authTokenExpired: + case ErrorCodes.syncAuthFailed: // App 侧实际不可达,防御性同文案。 + return errorAuthTokenExpired; + case ErrorCodes.validationFailed: + return errorValidation; + case ErrorCodes.networkError: + return errorNetwork; + case ErrorCodes.networkTimeout: + return errorNetworkTimeout; + case ErrorCodes.internalError: + return errorInternal; + default: + return errorGeneric; + } + } + + /// [AppException] 展示文案(按错误码解析)。 + String messageForError(AppException error) => messageForErrorCode(error.code); +} diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index f14d970..1b812de 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -20,5 +20,21 @@ "homeTabAlbums": "Albums", "homeTabQueue": "Queue", "homeTabSettings": "Settings", - "loginHint": "Sign in with your staff account" + "loginHint": "Sign in with your staff account", + "fieldRequired": "This field is required", + "passwordChangeTitle": "Change password", + "currentPassword": "Current password", + "newPassword": "New password", + "confirmNewPassword": "Confirm new password", + "passwordMinLength": "New password must be at least 8 characters", + "passwordMismatch": "New passwords do not match", + "passwordChangeSuccess": "Password changed", + "errorAuthInvalidCredentials": "Incorrect account or password", + "errorOldPasswordIncorrect": "Current password is incorrect", + "errorAuthRateLimited": "Too many attempts. Please try again later", + "errorAuthTokenExpired": "Session expired. Please sign in again", + "errorValidation": "Invalid input. Please check and retry", + "errorNetwork": "Network error. Check your connection and retry", + "errorNetworkTimeout": "Network timed out. Please retry", + "errorInternal": "Service temporarily unavailable. Please retry later" } diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 4dd9df0..3cfa148 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -223,6 +223,102 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'使用工作人员账号登录'** String get loginHint; + + /// No description provided for @fieldRequired. + /// + /// In zh, this message translates to: + /// **'此项为必填项'** + String get fieldRequired; + + /// No description provided for @passwordChangeTitle. + /// + /// In zh, this message translates to: + /// **'修改密码'** + String get passwordChangeTitle; + + /// No description provided for @currentPassword. + /// + /// In zh, this message translates to: + /// **'当前密码'** + String get currentPassword; + + /// No description provided for @newPassword. + /// + /// In zh, this message translates to: + /// **'新密码'** + String get newPassword; + + /// No description provided for @confirmNewPassword. + /// + /// In zh, this message translates to: + /// **'确认新密码'** + String get confirmNewPassword; + + /// No description provided for @passwordMinLength. + /// + /// In zh, this message translates to: + /// **'新密码至少需要 8 个字符'** + String get passwordMinLength; + + /// No description provided for @passwordMismatch. + /// + /// In zh, this message translates to: + /// **'两次输入的新密码不一致'** + String get passwordMismatch; + + /// No description provided for @passwordChangeSuccess. + /// + /// In zh, this message translates to: + /// **'密码已修改'** + String get passwordChangeSuccess; + + /// No description provided for @errorAuthInvalidCredentials. + /// + /// In zh, this message translates to: + /// **'账号或密码错误'** + String get errorAuthInvalidCredentials; + + /// No description provided for @errorOldPasswordIncorrect. + /// + /// In zh, this message translates to: + /// **'当前密码错误'** + String get errorOldPasswordIncorrect; + + /// No description provided for @errorAuthRateLimited. + /// + /// In zh, this message translates to: + /// **'尝试次数过多,请稍后再试'** + String get errorAuthRateLimited; + + /// No description provided for @errorAuthTokenExpired. + /// + /// In zh, this message translates to: + /// **'会话已过期,请重新登录'** + String get errorAuthTokenExpired; + + /// No description provided for @errorValidation. + /// + /// In zh, this message translates to: + /// **'输入有误,请检查后重试'** + String get errorValidation; + + /// No description provided for @errorNetwork. + /// + /// In zh, this message translates to: + /// **'网络连接失败,请检查网络后重试'** + String get errorNetwork; + + /// No description provided for @errorNetworkTimeout. + /// + /// In zh, this message translates to: + /// **'网络超时,请重试'** + String get errorNetworkTimeout; + + /// No description provided for @errorInternal. + /// + /// In zh, this message translates to: + /// **'服务暂时不可用,请稍后重试'** + String get errorInternal; } class _AppLocalizationsDelegate diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 0d6cac2..b8f1624 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -70,4 +70,54 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loginHint => 'Sign in with your staff account'; + + @override + String get fieldRequired => 'This field is required'; + + @override + String get passwordChangeTitle => 'Change password'; + + @override + String get currentPassword => 'Current password'; + + @override + String get newPassword => 'New password'; + + @override + String get confirmNewPassword => 'Confirm new password'; + + @override + String get passwordMinLength => 'New password must be at least 8 characters'; + + @override + String get passwordMismatch => 'New passwords do not match'; + + @override + String get passwordChangeSuccess => 'Password changed'; + + @override + String get errorAuthInvalidCredentials => 'Incorrect account or password'; + + @override + String get errorOldPasswordIncorrect => 'Current password is incorrect'; + + @override + String get errorAuthRateLimited => + 'Too many attempts. Please try again later'; + + @override + String get errorAuthTokenExpired => 'Session expired. Please sign in again'; + + @override + String get errorValidation => 'Invalid input. Please check and retry'; + + @override + String get errorNetwork => 'Network error. Check your connection and retry'; + + @override + String get errorNetworkTimeout => 'Network timed out. Please retry'; + + @override + String get errorInternal => + 'Service temporarily unavailable. Please retry later'; } diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index c7e4e69..3297045 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -70,4 +70,52 @@ class AppLocalizationsZh extends AppLocalizations { @override String get loginHint => '使用工作人员账号登录'; + + @override + String get fieldRequired => '此项为必填项'; + + @override + String get passwordChangeTitle => '修改密码'; + + @override + String get currentPassword => '当前密码'; + + @override + String get newPassword => '新密码'; + + @override + String get confirmNewPassword => '确认新密码'; + + @override + String get passwordMinLength => '新密码至少需要 8 个字符'; + + @override + String get passwordMismatch => '两次输入的新密码不一致'; + + @override + String get passwordChangeSuccess => '密码已修改'; + + @override + String get errorAuthInvalidCredentials => '账号或密码错误'; + + @override + String get errorOldPasswordIncorrect => '当前密码错误'; + + @override + String get errorAuthRateLimited => '尝试次数过多,请稍后再试'; + + @override + String get errorAuthTokenExpired => '会话已过期,请重新登录'; + + @override + String get errorValidation => '输入有误,请检查后重试'; + + @override + String get errorNetwork => '网络连接失败,请检查网络后重试'; + + @override + String get errorNetworkTimeout => '网络超时,请重试'; + + @override + String get errorInternal => '服务暂时不可用,请稍后重试'; } diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index ce60c6c..a0a4c58 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -20,5 +20,21 @@ "homeTabAlbums": "相册", "homeTabQueue": "队列", "homeTabSettings": "设置", - "loginHint": "使用工作人员账号登录" + "loginHint": "使用工作人员账号登录", + "fieldRequired": "此项为必填项", + "passwordChangeTitle": "修改密码", + "currentPassword": "当前密码", + "newPassword": "新密码", + "confirmNewPassword": "确认新密码", + "passwordMinLength": "新密码至少需要 8 个字符", + "passwordMismatch": "两次输入的新密码不一致", + "passwordChangeSuccess": "密码已修改", + "errorAuthInvalidCredentials": "账号或密码错误", + "errorOldPasswordIncorrect": "当前密码错误", + "errorAuthRateLimited": "尝试次数过多,请稍后再试", + "errorAuthTokenExpired": "会话已过期,请重新登录", + "errorValidation": "输入有误,请检查后重试", + "errorNetwork": "网络连接失败,请检查网络后重试", + "errorNetworkTimeout": "网络超时,请重试", + "errorInternal": "服务暂时不可用,请稍后重试" } diff --git a/apps/mobile/lib/pages/home_page.dart b/apps/mobile/lib/pages/home_page.dart index 14a7972..6507bfa 100644 --- a/apps/mobile/lib/pages/home_page.dart +++ b/apps/mobile/lib/pages/home_page.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api_client.dart'; import '../l10n/app_localizations.dart'; /// 主壳:底部导航(相册 / 队列 / 设置)。 @@ -89,12 +91,19 @@ class QueueTab extends StatelessWidget { } } -/// 设置(桩)。Task 10:App 内语言切换(zh / en / 跟随系统)持久化。 -class SettingsTab extends StatelessWidget { +/// 设置页(P3 Task 3:修改密码入口 + 退出登录;Task 10 补语言切换持久化)。 +class SettingsTab extends ConsumerWidget { const SettingsTab({super.key}); + Future _logout(BuildContext context, WidgetRef ref) async { + // api.logout 尽力而为(服务端撤销失败也照常本地清会话——客户端内已处理)。 + await ref.read(apiClientProvider).logout(); + if (!context.mounted) return; + Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false); + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); return Scaffold( appBar: AppBar(title: Text(l10n.settings)), @@ -105,6 +114,16 @@ class SettingsTab extends StatelessWidget { title: Text(l10n.language), subtitle: Text(l10n.localeFollowSystem), ), + ListTile( + leading: const Icon(Icons.password_outlined), + title: Text(l10n.passwordChangeTitle), + onTap: () => Navigator.of(context).pushNamed('/password-change'), + ), + ListTile( + leading: const Icon(Icons.logout), + title: Text(l10n.logout), + onTap: () => _logout(context, ref), + ), ], ), ); diff --git a/apps/mobile/lib/pages/login_page.dart b/apps/mobile/lib/pages/login_page.dart index bfb8274..867bd75 100644 --- a/apps/mobile/lib/pages/login_page.dart +++ b/apps/mobile/lib/pages/login_page.dart @@ -1,14 +1,65 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api_client.dart'; +import '../core/app_exception.dart'; +import '../core/error_l10n.dart'; import '../l10n/app_localizations.dart'; -/// 登录页(桩)。 +/// 登录页(P3 计划 Task 3)。 /// -/// Task 3 实现:真实登录、错误本地化(含 AUTH_RATE_LIMITED / 429 重试等待)、 -/// 会话持久化与退出登录。 -class LoginPage extends StatelessWidget { +/// 账号/密码表单 → [ApiClient.login](会话由客户端自动持久化到 +/// SessionStore)→ 成功 pushReplacement /home;失败按错误码本地化提示 +/// (AUTH_RATE_LIMITED 文案自带「稍后再试」;网络/校验错误各有专属文案)。 +/// 提交期间按钮进入加载态防重复提交。 +class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _accountController = TextEditingController(); + final _passwordController = TextEditingController(); + + bool _submitting = false; + + @override + void dispose() { + _accountController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _submitting = true); + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + try { + await ref.read(apiClientProvider).login( + _accountController.text.trim(), + _passwordController.text, + ); + navigator.pushReplacementNamed('/home'); + } on AppException catch (error) { + // 429(AUTH_RATE_LIMITED)等文案经错误码映射;登录页的 + // AUTH_INVALID_CREDENTIALS 即「账号或密码错误」。 + messenger.showSnackBar( + SnackBar(content: Text(l10n.messageForError(error))), + ); + } on Object { + messenger.showSnackBar( + SnackBar(content: Text(l10n.errorGeneric)), + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -19,40 +70,60 @@ class LoginPage extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 420), child: Padding( padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - decoration: InputDecoration( - labelText: l10n.username, - border: const OutlineInputBorder(), + child: Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _accountController, + autofillHints: const [AutofillHints.username], + decoration: InputDecoration( + labelText: l10n.username, + border: const OutlineInputBorder(), + ), + validator: (value) => + (value == null || value.trim().isEmpty) + ? l10n.fieldRequired + : null, ), - ), - const SizedBox(height: 16), - TextField( - obscureText: true, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - labelText: l10n.password, - border: const OutlineInputBorder(), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + autofillHints: const [AutofillHints.password], + onFieldSubmitted: (_) => _submitting ? null : _submit(), + decoration: InputDecoration( + labelText: l10n.password, + border: const OutlineInputBorder(), + ), + validator: (value) => (value == null || value.isEmpty) + ? l10n.fieldRequired + : null, ), - ), - const SizedBox(height: 24), - FilledButton( - // 桩:直接进入主壳;Task 3 接真实认证。 - onPressed: () => - Navigator.of(context).pushReplacementNamed('/home'), - child: Text(l10n.login), - ), - const SizedBox(height: 12), - Text( - l10n.loginHint, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), - ], + const SizedBox(height: 24), + FilledButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.login), + ), + const SizedBox(height: 12), + Text( + l10n.loginHint, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), ), ), ), diff --git a/apps/mobile/lib/pages/password_change_page.dart b/apps/mobile/lib/pages/password_change_page.dart new file mode 100644 index 0000000..1a250bd --- /dev/null +++ b/apps/mobile/lib/pages/password_change_page.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/api_client.dart'; +import '../core/app_exception.dart'; +import '../core/error_codes.dart'; +import '../core/error_l10n.dart'; +import '../l10n/app_localizations.dart'; + +/// 修改密码页(P3 计划 Task 3;入口在设置页)。 +/// +/// 当前/新/确认三字段;客户端校验(新密码 ≥8 位、两次一致)先行, +/// 服务端 [ApiClient.changePassword] 成功后 204——当前会话被服务端保留, +/// 无需重登录:toast 提示后直接 pop 回设置页。 +/// AUTH_INVALID_CREDENTIALS 在本页语义为「当前密码错误」(覆写通用映射)。 +class PasswordChangePage extends ConsumerStatefulWidget { + const PasswordChangePage({super.key}); + + @override + ConsumerState createState() => + _PasswordChangePageState(); +} + +class _PasswordChangePageState extends ConsumerState { + final _formKey = GlobalKey(); + final _oldController = TextEditingController(); + final _newController = TextEditingController(); + final _confirmController = TextEditingController(); + + bool _obscureAll = true; + bool _submitting = false; + + @override + void dispose() { + _oldController.dispose(); + _newController.dispose(); + _confirmController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _submitting = true); + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + try { + await ref.read(apiClientProvider).changePassword( + _oldController.text, + _newController.text, + ); + // 成功路径:先弹提示再 pop;pop 后 SnackBar 仍挂在根 messenger 上可见。 + messenger.showSnackBar( + SnackBar(content: Text(l10n.passwordChangeSuccess)), + ); + navigator.pop(); + } on AppException catch (error) { + messenger.showSnackBar( + SnackBar( + content: Text( + error.code == ErrorCodes.authInvalidCredentials + ? l10n.errorOldPasswordIncorrect + : l10n.messageForError(error), + ), + ), + ); + } on Object { + messenger.showSnackBar( + SnackBar(content: Text(l10n.errorGeneric)), + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar(title: Text(l10n.passwordChangeTitle)), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _oldController, + obscureText: _obscureAll, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + labelText: l10n.currentPassword, + border: const OutlineInputBorder(), + ), + validator: (value) => (value == null || value.isEmpty) + ? l10n.fieldRequired + : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _newController, + obscureText: _obscureAll, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + labelText: l10n.newPassword, + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.fieldRequired; + } + if (value.length < 8) return l10n.passwordMinLength; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _confirmController, + obscureText: _obscureAll, + enableSuggestions: false, + autocorrect: false, + onFieldSubmitted: (_) => _submitting ? null : _submit(), + decoration: InputDecoration( + labelText: l10n.confirmNewPassword, + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.fieldRequired; + } + if (value != _newController.text) { + return l10n.passwordMismatch; + } + return null; + }, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: IconButton( + onPressed: () => + setState(() => _obscureAll = !_obscureAll), + icon: Icon( + _obscureAll + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.passwordChangeTitle), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/test/app/app_shell_test.dart b/apps/mobile/test/app/app_shell_test.dart new file mode 100644 index 0000000..123bdef --- /dev/null +++ b/apps/mobile/test/app/app_shell_test.dart @@ -0,0 +1,57 @@ +/// App 壳 401 接线测试(P3 计划 Task 3)。 +/// +/// 覆盖:业务端点 401 → ApiClient 清会话并触发 onUnauthorized → +/// 根导航清栈回 /login(PhotoLiveApp.initState 注册的回调)。 +library; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/app/app.dart'; +import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/core/error_codes.dart'; +import 'package:photo_live/core/session_store.dart'; +import 'package:photo_live/pages/login_page.dart'; + +import '../helpers/fakes.dart'; + +void main() { + testWidgets('业务 401 → onUnauthorized 清栈回 /login', (tester) async { + final store = FakeSessionStore(); + store.session = Session( + token: 'tok-live-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'staff01', + ); + final adapter = FakeAdapter( + (o) => (401, errorBody(ErrorCodes.authTokenExpired)), + ); + final client = ApiClient( + sessionStore: store, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + // PhotoLiveApp.initState 读取被 override 的客户端并注册 onUnauthorized。 + await tester.pumpWidget( + ProviderScope( + overrides: [apiClientProvider.overrideWith((ref) => client)], + child: const PhotoLiveApp(), + ), + ); + await tester.pumpAndSettle(); + + // 先抵 /home 再触发 401:验证清栈(回 /login 而非叠加路由)。 + rootNavigatorKey.currentState! + .pushNamedAndRemoveUntil('/home', (route) => false); + await tester.pumpAndSettle(); + + // 触发 401 但不在测试体内 await(fake-async 下时钟由 pump 推进, + // 阻塞等待会死锁);AppException 由 ignore 吞掉,断言在下方。 + client.listAlbums().ignore(); + await tester.pumpAndSettle(); + + expect(store.session, isNull, reason: '业务 401 清空本地会话'); + expect(find.byType(LoginPage), findsOneWidget, reason: '清栈回登录页'); + }); +} diff --git a/apps/mobile/test/core/api_client_test.dart b/apps/mobile/test/core/api_client_test.dart index fbf8393..b67af44 100644 --- a/apps/mobile/test/core/api_client_test.dart +++ b/apps/mobile/test/core/api_client_test.dart @@ -509,4 +509,49 @@ void main() { expect(adapter.requests[1].method, 'DELETE'); expect(adapter.requests[1].path, '/v1/photos/pho-1'); }); + + test('changePassword:携带 Bearer 与双密码体;204 成功不动本地会话', () async { + seedSession(); + final adapter = FakeAdapter((o) => (204, null)); + + await wired(adapter).changePassword('old-pass-1', 'brand-new-9'); + + final req = adapter.requests.single; + expect(req.method, 'POST'); + expect(req.path, '/v1/auth/password-change'); + expect(req.headers['Authorization'], 'Bearer tok-live-1'); + expect(req.body, {'oldPassword': 'old-pass-1', 'newPassword': 'brand-new-9'}); + expect(store.session?.token, 'tok-live-1', reason: '当前会话保留'); + expect(unauthorizedCalls, 0); + }); + + test('changePassword:旧密码错误(401 AUTH_INVALID_CREDENTIALS)→ 不驱逐会话不通知', + () async { + seedSession(); + final adapter = FakeAdapter( + (o) => ( + 401, + { + 'code': 'AUTH_INVALID_CREDENTIALS', + 'message': 'old password wrong', + 'requestId': 'req-pc', + } + ), + ); + + final err = await captureError( + () => wired(adapter).changePassword('wrong-old', 'brand-new-9'), + ); + + expect( + err, + isA().having( + (e) => e.code, + 'code', + ErrorCodes.authInvalidCredentials, + ), + ); + expect(store.session?.token, 'tok-live-1', reason: '旧密码错误不清会话'); + expect(unauthorizedCalls, 0, reason: 'password-change 401 不驱逐'); + }); } diff --git a/apps/mobile/test/helpers/fakes.dart b/apps/mobile/test/helpers/fakes.dart new file mode 100644 index 0000000..6e1051d --- /dev/null +++ b/apps/mobile/test/helpers/fakes.dart @@ -0,0 +1,100 @@ +/// Widget/单元测试共用替身(P3 计划 Task 3)。 +/// +/// 与 test/core/api_client_test.dart 中的手写打桩同款:FakeAdapter 为 +/// dio HttpClientAdapter,按路由分发预设响应并记录全部请求;未引入 +/// http_mock_adapter(零额外 dev 依赖,打桩面完全可控)。 +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:photo_live/core/session_store.dart'; + +/// 内存会话存储(测试替身)。 +class FakeSessionStore implements SessionStore { + Session? session; + + @override + Future read() async => session; + + @override + Future save(Session value) async => session = value; + + @override + Future clear() async => session = null; +} + +/// 记录到适配器的请求快照。 +class RecordedRequest { + const RecordedRequest({ + required this.method, + required this.path, + required this.headers, + required this.body, + }); + + final String method; + final String path; + final Map headers; + final Object? body; +} + +/// 手写 dio 打桩适配器:按路由分发预设响应并记录全部请求。 +/// +/// [router] 返回 `(status, body)`(可为 async 以模拟慢响应/加载态); +/// body 为 null 表示无响应体(204)。 +class FakeAdapter implements HttpClientAdapter { + FakeAdapter(this.router); + + final FutureOr<(int, Object?)> Function(RequestOptions options) router; + + final List requests = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requests.add( + RecordedRequest( + method: options.method, + path: options.path, + headers: Map.from(options.headers), + body: options.data, + ), + ); + final (status, body) = await router(options); + if (body == null) { + // 204 等:空体且不带 JSON content-type(transformer 不解码)。 + return ResponseBody.fromString('', status); + } + return ResponseBody.fromString( + jsonEncode(body), + status, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +/// 常用响应体:登录成功(openapi LoginResponse 形状)。 +Map loginOkBody({ + String token = 'tok-new', + String expiresAt = '2026-09-20T12:00:00Z', +}) => { + 'sessionToken': token, + 'expiresAt': expiresAt, + }; + +/// 常用响应体:契约 Error 形状。 +Map errorBody(String code) => { + 'code': code, + 'message': 'stub message', + 'requestId': 'req-stub', + }; diff --git a/apps/mobile/test/pages/login_page_test.dart b/apps/mobile/test/pages/login_page_test.dart new file mode 100644 index 0000000..d012dff --- /dev/null +++ b/apps/mobile/test/pages/login_page_test.dart @@ -0,0 +1,155 @@ +/// 登录页 widget 测试(P3 计划 Task 3)。 +/// +/// 覆盖:空字段客户端校验(不发请求)、成功登录写会话并跳 /home、 +/// 凭据错误/限速(429)错误码本地化文案、提交中加载态(按钮禁用防重复提交)。 +/// API 层经 FakeAdapter(dio HttpClientAdapter)打桩,Provider 以 override +/// 注入测试客户端(无真实网络)。 +library; + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/core/error_codes.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/login_page.dart'; + +import '../helpers/fakes.dart'; + +void main() { + late FakeSessionStore store; + late int unauthorizedCalls; + + setUp(() { + store = FakeSessionStore(); + unauthorizedCalls = 0; + }); + + ApiClient clientOf(FakeAdapter adapter) => ApiClient( + sessionStore: store, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + )..onUnauthorized = () => unauthorizedCalls++; + + Future pumpLoginPage(WidgetTester tester, ApiClient client) async { + await tester.pumpWidget( + ProviderScope( + overrides: [apiClientProvider.overrideWith((ref) => client)], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const LoginPage(), + routes: { + '/home': (context) => + const Scaffold(body: Center(child: Text('HOME_MARK'))), + }, + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future fillAndSubmit(WidgetTester tester) async { + await tester.enterText( + find.widgetWithText(TextFormField, 'Account'), + 'staff01', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Password'), + 'pw-123456', + ); + await tester.tap(find.text('Sign in')); + await tester.pump(); // 触发提交(含 SnackBar 动画首帧)。 + } + + testWidgets('空字段 → 客户端校验提示,不发请求', (tester) async { + final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); + await pumpLoginPage(tester, clientOf(adapter)); + + await tester.tap(find.text('Sign in')); + await tester.pump(); + + // 测试环境默认 locale=en:账号/密码两个必填提示。 + expect(find.text('This field is required'), findsNWidgets(2)); + expect(adapter.requests, isEmpty); + }); + + testWidgets('成功 → 写会话并跳 /home', (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/auth/password' ? (200, loginOkBody()) + : throw StateError('unexpected ${o.path}'), + ); + await pumpLoginPage(tester, clientOf(adapter)); + + await fillAndSubmit(tester); + await tester.pumpAndSettle(); + + expect(find.text('HOME_MARK'), findsOneWidget); + expect(store.session?.token, 'tok-new'); + expect(store.session?.account, 'staff01'); + expect( + adapter.requests.single.body, + {'account': 'staff01', 'password': 'pw-123456'}, + ); + }); + + testWidgets('凭据错误(401)→ 「账号或密码错误」文案,不驱逐通知', (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/auth/password' + ? (401, errorBody(ErrorCodes.authInvalidCredentials)) + : throw StateError('unexpected ${o.path}'), + ); + await pumpLoginPage(tester, clientOf(adapter)); + + await fillAndSubmit(tester); + await tester.pumpAndSettle(); + + expect(find.text('Incorrect account or password'), findsOneWidget); + expect(store.session, isNull, reason: '登录失败不落会话'); + expect(unauthorizedCalls, 0, reason: '登录 401 不触发驱逐'); + expect(find.text('HOME_MARK'), findsNothing); + }); + + testWidgets('限速(429 AUTH_RATE_LIMITED)→ 稍后再试文案', (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/auth/password' + ? (429, errorBody(ErrorCodes.authRateLimited)) + : throw StateError('unexpected ${o.path}'), + ); + await pumpLoginPage(tester, clientOf(adapter)); + + await fillAndSubmit(tester); + await tester.pumpAndSettle(); + + expect( + find.text('Too many attempts. Please try again later'), + findsOneWidget, + ); + }); + + testWidgets('提交中 → 加载指示且按钮禁用(防重复提交)', (tester) async { + final gate = Completer(); + final adapter = FakeAdapter((o) async { + await gate.future; + return (200, loginOkBody()); + }); + await pumpLoginPage(tester, clientOf(adapter)); + + await fillAndSubmit(tester); + // dio 链路经真实定时器推进(fake-async 需步进时钟才抵达适配器)。 + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + // 加载中再点按钮(onPressed=null)不产生第二个请求。 + await tester.tap(find.byType(FilledButton), warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 50)); + expect(adapter.requests, hasLength(1)); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text('HOME_MARK'), findsOneWidget); + }); +} diff --git a/apps/mobile/test/pages/password_change_page_test.dart b/apps/mobile/test/pages/password_change_page_test.dart new file mode 100644 index 0000000..c9643bc --- /dev/null +++ b/apps/mobile/test/pages/password_change_page_test.dart @@ -0,0 +1,164 @@ +/// 修改密码页 widget 测试(P3 计划 Task 3)。 +/// +/// 覆盖:客户端校验(新密码 ≥8、两次一致;不发请求)、旧密码错误 +/// (401 AUTH_INVALID_CREDENTIALS)→「当前密码错误」文案且本地会话保留 +/// (password-change 的 401 不驱逐)、成功 → toast + pop 回上级且 +/// 会话令牌不变(服务端保留当前会话语义)。 +library; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/core/error_codes.dart'; +import 'package:photo_live/core/session_store.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/password_change_page.dart'; + +import '../helpers/fakes.dart'; + +void main() { + late FakeSessionStore store; + late int unauthorizedCalls; + + setUp(() { + store = FakeSessionStore(); + unauthorizedCalls = 0; + }); + + ApiClient clientOf(FakeAdapter adapter) => ApiClient( + sessionStore: store, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + )..onUnauthorized = () => unauthorizedCalls++; + + void seedSession() { + store.session = Session( + token: 'tok-live-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'staff01', + ); + } + + Future pumpOverMarker(WidgetTester tester, ApiClient client) async { + await tester.pumpWidget( + ProviderScope( + overrides: [apiClientProvider.overrideWith((ref) => client)], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: Center(child: Text('MARK'))), + routes: { + '/password-change': (context) => const PasswordChangePage(), + }, + ), + ), + ); + tester.state(find.byType(Navigator).first) + .pushNamed('/password-change'); + await tester.pumpAndSettle(); + } + + Future fill( + WidgetTester tester, { + String oldPw = 'old-pass-123', + String newPw = 'brand-new-pass-9', + String confirmPw = 'brand-new-pass-9', + }) async { + await tester.enterText( + find.widgetWithText(TextFormField, 'Current password'), + oldPw, + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'New password'), + newPw, + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Confirm new password'), + confirmPw, + ); + } + + testWidgets('新密码过短 → 客户端校验提示,不发请求', (tester) async { + seedSession(); + final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); + await pumpOverMarker(tester, clientOf(adapter)); + + await fill(tester, newPw: 'short7', confirmPw: 'short7'); + await tester.tap(find.widgetWithText(FilledButton, 'Change password')); + await tester.pump(); + + expect( + find.text('New password must be at least 8 characters'), + findsOneWidget, + ); + expect(adapter.requests, isEmpty); + }); + + testWidgets('两次新密码不一致 → 校验提示,不发请求', (tester) async { + seedSession(); + final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); + await pumpOverMarker(tester, clientOf(adapter)); + + await fill(tester, confirmPw: 'different-pass-9'); + await tester.tap(find.widgetWithText(FilledButton, 'Change password')); + await tester.pump(); + + expect( + find.text('New passwords do not match'), + findsOneWidget, + ); + expect(adapter.requests, isEmpty); + }); + + testWidgets('旧密码错误(401)→ 「当前密码错误」文案,会话保留不驱逐', (tester) async { + seedSession(); + final adapter = FakeAdapter( + (o) => o.path == '/v1/auth/password-change' + ? (401, errorBody(ErrorCodes.authInvalidCredentials)) + : throw StateError('unexpected ${o.path}'), + ); + await pumpOverMarker(tester, clientOf(adapter)); + + await fill(tester); + await tester.tap(find.widgetWithText(FilledButton, 'Change password')); + await tester.pumpAndSettle(); + + expect(find.text('Current password is incorrect'), findsOneWidget); + expect(store.session?.token, 'tok-live-1', reason: '旧密码错误不清会话'); + expect(unauthorizedCalls, 0, reason: 'password-change 的 401 不驱逐'); + expect(find.text('MARK'), findsNothing, reason: '停留在修改密码页'); + }); + + testWidgets('成功 → toast + pop 回上级;会话令牌不变(当前会话保留)', (tester) async { + seedSession(); + final adapter = FakeAdapter( + (o) => o.path == '/v1/auth/password-change' + ? (204, null) + : throw StateError('unexpected ${o.path}'), + ); + await pumpOverMarker(tester, clientOf(adapter)); + + await fill(tester); + await tester.tap(find.widgetWithText(FilledButton, 'Change password')); + await tester.pumpAndSettle(); + + final req = adapter.requests.single; + expect(req.method, 'POST'); + expect(req.path, '/v1/auth/password-change'); + expect(req.headers['Authorization'], 'Bearer tok-live-1'); + expect(req.body, { + 'oldPassword': 'old-pass-123', + 'newPassword': 'brand-new-pass-9', + }); + + expect(find.text('Password changed'), findsOneWidget); + expect(find.text('MARK'), findsOneWidget, reason: '成功后 pop 回上级'); + expect( + store.session?.token, + 'tok-live-1', + reason: '服务端保留当前会话:本地令牌无需变更', + ); + }); +} diff --git a/apps/mobile/test/pages/settings_tab_test.dart b/apps/mobile/test/pages/settings_tab_test.dart new file mode 100644 index 0000000..102253a --- /dev/null +++ b/apps/mobile/test/pages/settings_tab_test.dart @@ -0,0 +1,88 @@ +/// 设置页 widget 测试(P3 计划 Task 3)。 +/// +/// 覆盖:修改密码入口跳 /password-change;退出登录 → 调 api.logout +/// (尽力而为)→ 清空本地会话 → 清栈回 /login。 +library; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/core/session_store.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/home_page.dart'; +import 'package:photo_live/pages/login_page.dart'; +import 'package:photo_live/pages/password_change_page.dart'; + +import '../helpers/fakes.dart'; + +void main() { + late FakeSessionStore store; + + setUp(() { + store = FakeSessionStore(); + store.session = Session( + token: 'tok-live-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'staff01', + ); + }); + + ApiClient clientOf(FakeAdapter adapter) => ApiClient( + sessionStore: store, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + Future pumpHome(WidgetTester tester, ApiClient client) async { + await tester.pumpWidget( + ProviderScope( + overrides: [apiClientProvider.overrideWith((ref) => client)], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const HomePage(), + routes: { + '/login': (context) => const LoginPage(), + '/password-change': (context) => const PasswordChangePage(), + }, + ), + ), + ); + await tester.pumpAndSettle(); + // 切到设置 tab(底部导航第三项)。 + await tester.tap(find.byIcon(Icons.settings_outlined)); + await tester.pumpAndSettle(); + } + + testWidgets('修改密码入口 → 跳 /password-change', (tester) async { + final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); + await pumpHome(tester, clientOf(adapter)); + + await tester.tap(find.text('Change password')); + await tester.pumpAndSettle(); + + expect(find.byType(PasswordChangePage), findsOneWidget); + }); + + testWidgets('退出登录 → api.logout + 清会话 + 清栈回 /login(服务端失败亦然)', + (tester) async { + // 服务端撤销失败(500)也照样本地登出——尽力而为语义。 + final adapter = FakeAdapter( + (o) => o.path == '/v1/auth/logout' + ? (500, errorBody('INTERNAL_ERROR')) + : throw StateError('unexpected ${o.path}'), + ); + await pumpHome(tester, clientOf(adapter)); + + await tester.tap(find.text('Sign out')); + await tester.pumpAndSettle(); + + expect(adapter.requests.single.path, '/v1/auth/logout'); + expect(adapter.requests.single.method, 'POST'); + expect(store.session, isNull, reason: '本地会话必须清除'); + expect(find.byType(LoginPage), findsOneWidget); + expect(find.byType(HomePage), findsNothing, reason: '主壳已出栈'); + }); +}