- 환경/라우터 모듈에 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
386 lines
12 KiB
Dart
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();
|
|
}
|
|
}
|