Files
superport_v2/test/widgets/app_shell_test.dart
JiWoong Sul 753f76e952 feat(menu-permissions): 메뉴 API 연동으로 사이드바 권한 정비
- .env.development.example과 lib/core/config/environment.dart, lib/core/permissions/permission_manager.dart에서 PERMISSION__ 폴백을 view 전용으로 좁히고 기본 정책을 명시적으로 거부하도록 재정비했다

- lib/core/navigation/*, lib/core/routing/app_router.dart, lib/widgets/app_shell.dart, lib/main.dart에서 메뉴 매니페스트·카탈로그를 도입해 /menus 응답을 캐싱하고 라우터·사이드바·Breadcrumb가 동일 menu_code/route_path를 쓰도록 리팩터링했다

- lib/core/permissions/permission_resources.dart와 그룹 권한/메뉴 마스터 모듈을 menu_code 기반 CRUD 및 Catalog 경로 정합성 검사로 전환하고 PermissionSynchronizer·PermissionBootstrapper를 확장했다

- test/helpers/test_permissions.dart, test/widgets/app_shell_test.dart 등 신규 구조를 반영하는 테스트·골든과 doc/frontend_menu_permission_tasks.md 문서를 보강했다
2025-11-12 18:29:03 +09:00

462 lines
14 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/navigation/menu_catalog.dart';
import 'package:superport_v2/core/navigation/route_paths.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/menu/domain/entities/menu.dart';
import 'package:superport_v2/features/masters/menu/domain/repositories/menu_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();
if (!GetIt.I.isRegistered<MenuRepository>()) {
GetIt.I.registerSingleton<MenuRepository>(_StubMenuRepository());
}
final menuCatalog = MenuCatalog(repository: GetIt.I<MenuRepository>());
await menuCatalog.refresh();
if (GetIt.I.isRegistered<MenuCatalog>()) {
GetIt.I.unregister<MenuCatalog>();
}
GetIt.I.registerSingleton<MenuCatalog>(menuCatalog);
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(menuCatalog.dispose);
addTearDown(router.dispose);
await tester.pumpWidget(
PermissionScope(
manager: permissionManager,
child: MenuCatalogScope(
catalog: menuCatalog,
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 _StubMenuRepository implements MenuRepository {
@override
Future<MenuItem> create(MenuInput input) {
throw UnimplementedError();
}
@override
Future<void> delete(int id) {
throw UnimplementedError();
}
@override
Future<PaginatedResult<MenuItem>> list({
int page = 1,
int pageSize = 20,
String? query,
int? parentId,
bool? isActive,
bool includeDeleted = false,
}) async {
return PaginatedResult<MenuItem>(
items: [
MenuItem(
id: 1,
menuCode: 'dashboard',
menuName: '대시보드',
path: dashboardRoutePath,
displayOrder: 10,
),
MenuItem(
id: 2,
menuCode: 'inventory.receipts',
menuName: '입고',
path: inventoryReceiptsRoutePath,
displayOrder: 20,
parent: MenuSummary(
id: 10,
menuName: '재고',
menuCode: 'inventory',
path: inventorySummaryRoutePath,
),
),
],
page: 1,
pageSize: 2,
total: 2,
);
}
@override
Future<MenuItem> restore(int id) {
throw UnimplementedError();
}
@override
Future<MenuItem> update(int id, MenuInput input) {
throw UnimplementedError();
}
}
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();
}
}