Files
photo-live/apps/mobile/test/pages/password_change_page_test.dart
T
浅念 8be21b856b feat(mobile): login, password change and logout screens
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 <noreply@anthropic.com>
2026-09-19 01:19:57 +08:00

165 lines
5.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 修改密码页 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<void> 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<NavigatorState>(find.byType(Navigator).first)
.pushNamed('/password-change');
await tester.pumpAndSettle();
}
Future<void> 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: '服务端保留当前会话:本地令牌无需变更',
);
});
}