Files
superport_v2/test/widgets/app_shell_test.dart
JiWoong Sul d76f765814 feat(approvals): Approval Flow v2 프런트엔드 전면 개편
- 환경/라우터 모듈에 approval_flow_v2 토글을 추가하고 FeatureFlags 초기화를 연결 (.env*, lib/core/**)
- ApiClient 빌더·ApiRoutes 확장과 ApprovalRepositoryRemote 리팩터링으로 include·액션 시그니처를 정합화
- ApprovalFlow·ApprovalDraft 엔티티/레포/유즈케이스를 도입해 서버 초안과 단계 액션(승인·회수·재상신)을 지원
- Approval 컨트롤러·히스토리·템플릿 페이지와 공유 위젯을 재작성해 감사 로그·회수 UX·템플릿 CRUD를 반영
- Inbound/Outbound/Rental 컨트롤러·페이지에 결재 섹션을 삽입하고 대시보드 pending 카드 요약을 갱신
- SuperportDialog·FormField 등 공통 위젯을 보강하고 승인 위젯 가이드를 추가해 UI 가이드를 정리
- 결재/재고 테스트 픽스처와 단위·위젯·통합 테스트를 확장하고 flutter_test_config로 스테이징 호스트를 허용
- Approval Flow 레포트/플랜 문서를 업데이트하고 ApprovalFlow_System_Integration_and_ChangePlan.md를 추가
- 실행: flutter analyze, flutter test
2025-10-31 01:05:39 +09:00

386 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/constants/app_sections.dart';
import 'package:superport_v2/core/permissions/permission_manager.dart';
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
import 'package:superport_v2/core/theme/theme_controller.dart';
import 'package:superport_v2/core/services/token_storage.dart';
import 'package:superport_v2/features/auth/application/auth_service.dart';
import 'package:superport_v2/features/auth/domain/entities/auth_permission.dart';
import 'package:superport_v2/features/auth/domain/entities/auth_session.dart';
import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart';
import 'package:superport_v2/features/auth/domain/entities/login_request.dart';
import 'package:superport_v2/features/auth/domain/repositories/auth_repository.dart';
import 'package:superport_v2/features/masters/user/domain/entities/user.dart';
import 'package:superport_v2/features/masters/user/domain/repositories/user_repository.dart';
import 'package:superport_v2/widgets/app_shell.dart';
void main() {
setUp(() {
GetIt.I.reset();
});
testWidgets('계정 버튼을 누르면 계정 정보 다이얼로그가 표시된다', (tester) async {
final session = _buildSession();
final authService = _createAuthService(session);
GetIt.I.registerSingleton<AuthService>(authService);
GetIt.I.registerSingleton<UserRepository>(_StubUserRepository());
addTearDown(authService.dispose);
await authService.login(
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
);
await _pumpAppShell(tester);
await tester.tap(find.byIcon(lucide.LucideIcons.userRound));
await tester.pumpAndSettle();
expect(find.text('내 정보'), findsOneWidget);
expect(find.text('김승인'), findsWidgets);
expect(find.text('E2025001'), findsOneWidget);
expect(find.text('물류팀'), findsOneWidget);
expect(find.text('연락처 / 이메일 수정'), findsOneWidget);
expect(find.text('비밀번호 변경'), findsOneWidget);
expect(find.textContaining('/approvals'), findsOneWidget);
});
testWidgets('다이얼로그에서 로그아웃을 누르면 세션이 초기화되고 로그인 화면으로 이동한다', (tester) async {
final session = _buildSession();
final authService = _createAuthService(session);
GetIt.I.registerSingleton<AuthService>(authService);
GetIt.I.registerSingleton<UserRepository>(_StubUserRepository());
addTearDown(authService.dispose);
await authService.login(
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
);
await _pumpAppShell(tester);
expect(authService.session, isNotNull);
await tester.tap(find.byIcon(lucide.LucideIcons.userRound));
await tester.pumpAndSettle();
await tester.tap(find.text('로그아웃'));
await tester.pumpAndSettle();
expect(authService.session, isNull);
expect(find.text('로그인 페이지'), findsOneWidget);
});
testWidgets('내 정보 다이얼로그에서 연락처/이메일 저장 시 updateMe 호출', (tester) async {
final session = _buildSession();
final authService = _createAuthService(session);
final captured = <UserProfileUpdateInput>[];
final repository = _StubUserRepository(
onUpdateMe: (input) async {
captured.add(input);
return UserAccount(
id: session.user.id,
employeeNo: session.user.employeeNo ?? '',
employeeName: session.user.name,
email: input.email,
mobileNo: input.phone,
group: UserGroup(id: 1, groupName: '물류팀'),
);
},
);
GetIt.I.registerSingleton<AuthService>(authService);
GetIt.I.registerSingleton<UserRepository>(repository);
addTearDown(authService.dispose);
await authService.login(
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
);
final view = tester.view;
view.physicalSize = const Size(1600, 900);
view.devicePixelRatio = 1.0;
addTearDown(() {
view.resetPhysicalSize();
view.resetDevicePixelRatio();
});
await _pumpAppShell(tester);
await tester.tap(find.byIcon(lucide.LucideIcons.userRound));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('account_email_field')),
'new@superport.com',
);
await tester.enterText(
find.byKey(const ValueKey('account_phone_field')),
'+82-10-9999-8888',
);
await tester.tap(find.text('저장'));
await tester.pumpAndSettle();
expect(captured, hasLength(1));
final input = captured.single;
expect(input.email, 'new@superport.com');
expect(input.phone, '+82-10-9999-8888');
expect(authService.session?.user.email, 'new@superport.com');
expect(authService.session?.user.phone, '+82-10-9999-8888');
});
testWidgets('비밀번호 변경 완료 시 강제 로그아웃 안내 후 세션 초기화', (tester) async {
final session = _buildSession();
final authService = _createAuthService(session);
UserProfileUpdateInput? passwordInput;
final repository = _StubUserRepository(
onUpdateMe: (input) async {
passwordInput = input;
return UserAccount(
id: session.user.id,
employeeNo: session.user.employeeNo ?? '',
employeeName: session.user.name,
email: input.email ?? session.user.email,
mobileNo: input.phone ?? session.user.phone,
group: UserGroup(id: 1, groupName: '물류팀'),
);
},
);
GetIt.I.registerSingleton<AuthService>(authService);
GetIt.I.registerSingleton<UserRepository>(repository);
addTearDown(authService.dispose);
await authService.login(
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
);
final view = tester.view;
view.physicalSize = const Size(1600, 900);
view.devicePixelRatio = 1.0;
addTearDown(() {
view.resetPhysicalSize();
view.resetDevicePixelRatio();
});
await _pumpAppShell(tester);
await tester.tap(find.byIcon(lucide.LucideIcons.userRound));
await tester.pumpAndSettle();
final changeButton = find.text('비밀번호 변경');
await tester.ensureVisible(changeButton);
await tester.tap(changeButton);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('account_current_password')),
'TempPass1!',
);
await tester.enterText(
find.byKey(const ValueKey('account_new_password')),
'Aa1!zzzz',
);
await tester.enterText(
find.byKey(const ValueKey('account_confirm_password')),
'Aa1!zzzz',
);
await tester.tap(find.text('변경'));
await tester.pump();
expect(passwordInput, isNotNull);
expect(passwordInput!.currentPassword, 'TempPass1!');
expect(passwordInput!.password, 'Aa1!zzzz');
await tester.pumpAndSettle();
expect(find.text('비밀번호 변경 완료'), findsOneWidget);
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(authService.session, isNull);
expect(find.text('로그인 페이지'), findsOneWidget);
});
}
Future<void> _pumpAppShell(WidgetTester tester) async {
final themeController = ThemeController();
final permissionManager = PermissionManager();
final router = GoRouter(
initialLocation: dashboardRoutePath,
routes: [
GoRoute(
path: loginRoutePath,
builder: (context, state) => const _LoginPlaceholder(),
),
ShellRoute(
builder: (context, state, child) =>
AppShell(currentLocation: state.uri.toString(), child: child),
routes: [
GoRoute(
path: dashboardRoutePath,
builder: (context, state) => const _DashboardPlaceholder(),
),
],
),
],
);
addTearDown(themeController.dispose);
addTearDown(permissionManager.dispose);
addTearDown(router.dispose);
await tester.pumpWidget(
PermissionScope(
manager: permissionManager,
child: ThemeControllerScope(
controller: themeController,
child: ShadApp.router(
routerConfig: router,
theme: SuperportShadTheme.light(),
darkTheme: SuperportShadTheme.dark(),
debugShowCheckedModeBanner: false,
),
),
),
);
await tester.pumpAndSettle();
}
AuthSession _buildSession() {
return AuthSession(
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: DateTime.parse('2025-10-21T09:00:00Z'),
user: const AuthenticatedUser(
id: 7,
name: '김승인',
employeeNo: 'E2025001',
email: 'approver@example.com',
phone: '+82-10-2222-1111',
primaryGroupId: 3,
primaryGroupName: '물류팀',
),
permissions: const [
AuthPermission(resource: '/dashboard', actions: ['read']),
AuthPermission(resource: '/approvals', actions: ['read', 'update']),
],
);
}
AuthService _createAuthService(AuthSession session) {
final repository = _FakeAuthRepository(session);
final tokenStorage = _MemoryTokenStorage();
return AuthService(repository: repository, tokenStorage: tokenStorage);
}
class _FakeAuthRepository implements AuthRepository {
_FakeAuthRepository(this.session);
final AuthSession session;
@override
Future<AuthSession> login(LoginRequest request) async => session;
@override
Future<AuthSession> refresh(String refreshToken) async => session;
}
class _MemoryTokenStorage implements TokenStorage {
String? _access;
String? _refresh;
@override
Future<void> clear() async {
_access = null;
_refresh = null;
}
@override
Future<String?> readAccessToken() async => _access;
@override
Future<String?> readRefreshToken() async => _refresh;
@override
Future<void> writeAccessToken(String? token) async {
_access = token;
}
@override
Future<void> writeRefreshToken(String? token) async {
_refresh = token;
}
}
class _DashboardPlaceholder extends StatelessWidget {
const _DashboardPlaceholder();
@override
Widget build(BuildContext context) {
return const Scaffold(body: Center(child: Text('대시보드 본문')));
}
}
class _LoginPlaceholder extends StatelessWidget {
const _LoginPlaceholder();
@override
Widget build(BuildContext context) {
return const Scaffold(body: Center(child: Text('로그인 페이지')));
}
}
class _StubUserRepository implements UserRepository {
_StubUserRepository({this.onUpdateMe});
final Future<UserAccount> Function(UserProfileUpdateInput input)? onUpdateMe;
@override
Future<UserAccount> create(UserInput input) {
throw UnimplementedError();
}
@override
Future<void> delete(int id) {
throw UnimplementedError();
}
@override
Future<PaginatedResult<UserAccount>> list({
int page = 1,
int pageSize = 20,
String? query,
int? groupId,
bool? isActive,
}) {
throw UnimplementedError();
}
@override
Future<UserAccount> resetPassword(int id) {
throw UnimplementedError();
}
@override
Future<UserAccount> restore(int id) {
throw UnimplementedError();
}
@override
Future<UserAccount> update(int id, UserInput input) {
throw UnimplementedError();
}
@override
Future<UserAccount> updateMe(UserProfileUpdateInput input) {
if (onUpdateMe != null) {
return onUpdateMe!(input);
}
throw UnimplementedError();
}
}