Files
photo-live/apps/mobile/test/pages/login_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

156 lines
5.3 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)。
///
/// 覆盖:空字段客户端校验(不发请求)、成功登录写会话并跳 /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<void> 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<void> 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<void>();
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);
});
}