feat(mobile): album management pages with qr share
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
/// 成员管理页 widget 测试(P3 计划 Task 4)。
|
||||
///
|
||||
/// 覆盖:列表角色标签/owner 徽标/本人与创建者不可移除;账号前缀搜索
|
||||
/// 防抖(300ms、≥2 字符、连续输入合并);邀请载荷与 MEMBER_CONFLICT
|
||||
/// 本地化;移除确认流。
|
||||
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/features/albums/models.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/album_members_page.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore store;
|
||||
late List<Map<String, dynamic>> members;
|
||||
|
||||
setUp(() {
|
||||
store = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-live',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'staff01',
|
||||
);
|
||||
members = <Map<String, dynamic>>[
|
||||
memberJson(
|
||||
userId: 'u-owner',
|
||||
account: 'boss01',
|
||||
displayName: 'The Owner',
|
||||
role: 'owner',
|
||||
),
|
||||
memberJson(userId: 'u-1', account: 'staff01', displayName: 'Staff One'),
|
||||
memberJson(userId: 'u-2', account: 'staff02', displayName: 'Staff Two'),
|
||||
];
|
||||
});
|
||||
|
||||
FakeAdapter membersAdapter({
|
||||
(int, Object?)? Function(String method, String path)? onMutation,
|
||||
}) =>
|
||||
FakeAdapter((o) {
|
||||
if (o.path == '/v1/albums/alb-1/members' && o.method == 'GET') {
|
||||
return (200, cursorPageBody(members));
|
||||
}
|
||||
if (o.path == '/v1/albums/alb-1/members' && o.method == 'POST') {
|
||||
final override = onMutation?.call('POST', o.path);
|
||||
if (override != null) return override;
|
||||
members = <Map<String, dynamic>>[
|
||||
...members,
|
||||
memberJson(userId: 'u-9', account: 'staff09'),
|
||||
];
|
||||
return (201, memberJson(userId: 'u-9', account: 'staff09'));
|
||||
}
|
||||
if (o.path == '/v1/albums/alb-1/members/u-2' && o.method == 'DELETE') {
|
||||
final override = onMutation?.call('DELETE', o.path);
|
||||
if (override != null) return override;
|
||||
members = members
|
||||
.where((m) => m['userId'] != 'u-2')
|
||||
.toList();
|
||||
return (204, null);
|
||||
}
|
||||
if (o.path == '/v1/admin/users' && o.method == 'GET') {
|
||||
return (200, <String, dynamic>{
|
||||
'items': <Map<String, dynamic>>[lookupUserJson()],
|
||||
});
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
|
||||
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
|
||||
sessionStore: store,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
Future<void> pumpMembers(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
apiClientProvider.overrideWith((ref) => client),
|
||||
// 本人识别走会话存储:覆写为内存假件(secure storage 插件缺失)。
|
||||
sessionStoreProvider.overrideWith((ref) => store),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: AlbumMembersPage(
|
||||
album: Album.fromJson(albumJson()),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('列表渲染:角色标签、owner 徽标、本人/创建者无移除按钮',
|
||||
(tester) async {
|
||||
await pumpMembers(tester, clientOf(membersAdapter()));
|
||||
|
||||
expect(find.text('Owner'), findsOneWidget);
|
||||
expect(find.text('Collaborator'), findsNWidgets(2));
|
||||
expect(find.byIcon(Icons.star), findsOneWidget, reason: 'owner 徽标');
|
||||
// 本人标记。
|
||||
expect(find.text('staff01 (you)'), findsOneWidget);
|
||||
// 仅 staff02(非 owner、非本人)有移除按钮。
|
||||
expect(find.byIcon(Icons.person_remove_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('搜索防抖:<2 字符不请求;连续输入合并为最后一次(300ms)',
|
||||
(tester) async {
|
||||
final adapter = membersAdapter();
|
||||
await pumpMembers(tester, clientOf(adapter));
|
||||
|
||||
final field = find.byType(TextField);
|
||||
// 1 个字符:无请求。
|
||||
await tester.enterText(field, 's');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(
|
||||
adapter.requests.where((r) => r.path == '/v1/admin/users'),
|
||||
isEmpty,
|
||||
);
|
||||
|
||||
// 连续输入两次,防抖合并为最后一次。
|
||||
await tester.enterText(field, 'st');
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.enterText(field, 'sta');
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
|
||||
final lookups = adapter.requests
|
||||
.where((r) => r.path == '/v1/admin/users')
|
||||
.toList();
|
||||
expect(lookups, hasLength(1));
|
||||
expect(lookups.single.uri.queryParameters['accountPrefix'], 'sta');
|
||||
});
|
||||
|
||||
testWidgets('搜索命中 → 点击邀请 → POST userId 载荷 → 列表刷新 + 提示',
|
||||
(tester) async {
|
||||
final adapter = membersAdapter();
|
||||
await pumpMembers(tester, clientOf(adapter));
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'staff09');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
// 搜索框 EditableText 与结果条目同名,按 ListTile 定位结果。
|
||||
expect(find.widgetWithText(ListTile, 'staff09'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Invite'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final invite = adapter.requests.singleWhere(
|
||||
(r) => r.method == 'POST' && r.path == '/v1/albums/alb-1/members',
|
||||
);
|
||||
expect(invite.body, <String, dynamic>{'userId': 'u-9'});
|
||||
expect(find.text('Invited'), findsOneWidget);
|
||||
// 邀请成功后列表重载(staff09 入列)。
|
||||
expect(find.text('Collaborator'), findsNWidgets(3));
|
||||
});
|
||||
|
||||
testWidgets('邀请冲突(409 MEMBER_CONFLICT)→ 已是成员文案', (tester) async {
|
||||
final adapter = membersAdapter(
|
||||
onMutation: (method, path) =>
|
||||
method == 'POST' ? (409, errorBody(ErrorCodes.memberConflict)) : null,
|
||||
);
|
||||
await pumpMembers(tester, clientOf(adapter));
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'staff09');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.tap(find.text('Invite'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('User is already a member'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('移除成员:确认对话框门控 → DELETE → 列表刷新', (tester) async {
|
||||
final adapter = membersAdapter();
|
||||
await pumpMembers(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.person_remove_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Remove this member?'), findsOneWidget);
|
||||
// 取消不发请求。
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
adapter.requests.any((r) => r.method == 'DELETE'),
|
||||
isFalse,
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.person_remove_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
adapter.requests.any(
|
||||
(r) => r.method == 'DELETE' && r.path == '/v1/albums/alb-1/members/u-2',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(find.text('staff02'), findsNothing);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user