- .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 문서를 보강했다
371 lines
11 KiB
Dart
371 lines
11 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:mocktail/mocktail.dart';
|
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
|
import 'package:superport_v2/core/config/environment.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/services/token_storage.dart';
|
|
import 'package:superport_v2/features/auth/application/auth_service.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/login/presentation/pages/login_page.dart';
|
|
import 'package:superport_v2/features/masters/group/domain/entities/group.dart';
|
|
import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart';
|
|
import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart';
|
|
import 'package:superport_v2/features/masters/group_permission/domain/repositories/group_permission_repository.dart';
|
|
|
|
import '../helpers/test_app.dart';
|
|
|
|
GoRouter _createTestRouter() {
|
|
return GoRouter(
|
|
initialLocation: loginRoutePath,
|
|
routes: [
|
|
GoRoute(
|
|
path: loginRoutePath,
|
|
builder: (context, state) => const LoginPage(),
|
|
),
|
|
GoRoute(
|
|
path: dashboardRoutePath,
|
|
builder: (context, state) => const _TestDashboardPage(),
|
|
),
|
|
GoRoute(
|
|
path: inventoryReceiptsRoutePath,
|
|
builder: (context, state) => const _PlaceholderPage(title: '입고 화면'),
|
|
),
|
|
GoRoute(
|
|
path: inventoryIssuesRoutePath,
|
|
builder: (context, state) => const _PlaceholderPage(title: '출고 화면'),
|
|
),
|
|
GoRoute(
|
|
path: inventoryRentalsRoutePath,
|
|
builder: (context, state) => const _PlaceholderPage(title: '대여 화면'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
class _TestApp extends StatelessWidget {
|
|
const _TestApp({required this.router, required this.catalog});
|
|
|
|
final GoRouter router;
|
|
final MenuCatalog catalog;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PermissionScope(
|
|
manager: PermissionManager(),
|
|
child: MenuCatalogScope(
|
|
catalog: catalog,
|
|
child: ShadApp.router(
|
|
routerConfig: router,
|
|
debugShowCheckedModeBanner: false,
|
|
theme: SuperportShadTheme.light(),
|
|
darkTheme: SuperportShadTheme.dark(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TestDashboardPage extends StatelessWidget {
|
|
const _TestDashboardPage();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('테스트 대시보드'),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: '로그아웃',
|
|
icon: const Icon(Icons.logout),
|
|
onPressed: () => context.go(loginRoutePath),
|
|
),
|
|
],
|
|
),
|
|
body: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => context.go(inventoryReceiptsRoutePath),
|
|
child: const Text('입고로 이동'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => context.go(inventoryIssuesRoutePath),
|
|
child: const Text('출고로 이동'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => context.go(inventoryRentalsRoutePath),
|
|
child: const Text('대여로 이동'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlaceholderPage extends StatelessWidget {
|
|
const _PlaceholderPage({required this.title});
|
|
|
|
final String title;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(title)),
|
|
body: Center(
|
|
child: TextButton(
|
|
onPressed: () => context.go(dashboardRoutePath),
|
|
child: const Text('대시보드로 돌아가기'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StubGroupRepository implements GroupRepository {
|
|
@override
|
|
Future<Group> create(GroupInput input) {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
@override
|
|
Future<void> delete(int id) {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
@override
|
|
Future<PaginatedResult<Group>> list({
|
|
int page = 1,
|
|
int pageSize = 20,
|
|
String? query,
|
|
bool? isDefault,
|
|
bool? isActive,
|
|
bool includePermissions = false,
|
|
bool includeEmployees = false,
|
|
}) async {
|
|
return PaginatedResult<Group>(
|
|
items: [
|
|
Group(
|
|
id: 1,
|
|
groupName: '기본 그룹',
|
|
description: '테스트',
|
|
isDefault: true,
|
|
isActive: true,
|
|
),
|
|
],
|
|
page: 1,
|
|
pageSize: 1,
|
|
total: 1,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<Group> restore(int id) {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
@override
|
|
Future<Group> update(int id, GroupInput input) {
|
|
throw UnimplementedError();
|
|
}
|
|
}
|
|
|
|
class _StubGroupPermissionRepository implements GroupPermissionRepository {
|
|
@override
|
|
Future<GroupPermission> create(GroupPermissionInput input) {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
@override
|
|
Future<void> delete(int id) {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
@override
|
|
Future<PaginatedResult<GroupPermission>> list({
|
|
int page = 1,
|
|
int pageSize = 20,
|
|
int? groupId,
|
|
String? menuCode,
|
|
bool? isActive,
|
|
bool includeDeleted = false,
|
|
}) async {
|
|
return PaginatedResult<GroupPermission>(
|
|
items: [
|
|
GroupPermission(
|
|
id: 1,
|
|
group: GroupPermissionGroup(id: groupId ?? 1, groupName: '기본 그룹'),
|
|
menu: GroupPermissionMenu(
|
|
id: 10,
|
|
menuCode: 'DASHBOARD',
|
|
menuName: '대시보드',
|
|
path: dashboardRoutePath,
|
|
),
|
|
canCreate: true,
|
|
canRead: true,
|
|
canUpdate: true,
|
|
canDelete: true,
|
|
),
|
|
],
|
|
page: 1,
|
|
pageSize: 1,
|
|
total: 1,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<GroupPermission> restore(int id) {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
@override
|
|
Future<GroupPermission> update(int id, GroupPermissionInput input) {
|
|
throw UnimplementedError();
|
|
}
|
|
}
|
|
|
|
class _MockAuthRepository extends Mock implements AuthRepository {}
|
|
|
|
class _FakeTokenStorage implements TokenStorage {
|
|
String? _accessToken;
|
|
String? _refreshToken;
|
|
|
|
@override
|
|
Future<void> clear() async {
|
|
_accessToken = null;
|
|
_refreshToken = null;
|
|
}
|
|
|
|
@override
|
|
Future<String?> readAccessToken() async => _accessToken;
|
|
|
|
@override
|
|
Future<String?> readRefreshToken() async => _refreshToken;
|
|
|
|
@override
|
|
Future<void> writeAccessToken(String? token) async {
|
|
_accessToken = token;
|
|
}
|
|
|
|
@override
|
|
Future<void> writeRefreshToken(String? token) async {
|
|
_refreshToken = token;
|
|
}
|
|
}
|
|
|
|
AuthSession _buildSampleSession() {
|
|
return const AuthSession(
|
|
accessToken: 'access-token',
|
|
refreshToken: 'refresh-token',
|
|
expiresAt: null,
|
|
user: AuthenticatedUser(id: 1, name: '테스터', phone: null),
|
|
permissions: [],
|
|
);
|
|
}
|
|
|
|
void _registerAuthService(
|
|
_MockAuthRepository repository,
|
|
_FakeTokenStorage storage,
|
|
) {
|
|
final service = AuthService(repository: repository, tokenStorage: storage);
|
|
when(
|
|
() => repository.login(any()),
|
|
).thenAnswer((_) async => _buildSampleSession());
|
|
when(() => repository.refresh(any())).thenThrow(UnimplementedError());
|
|
GetIt.I.registerSingleton<AuthService>(service);
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUpAll(() {
|
|
registerFallbackValue(
|
|
LoginRequest(identifier: '', password: '', rememberMe: false),
|
|
);
|
|
});
|
|
|
|
late _MockAuthRepository authRepository;
|
|
late _FakeTokenStorage tokenStorage;
|
|
|
|
setUp(() async {
|
|
await GetIt.I.reset();
|
|
authRepository = _MockAuthRepository();
|
|
tokenStorage = _FakeTokenStorage();
|
|
_registerAuthService(authRepository, tokenStorage);
|
|
});
|
|
|
|
setUpAll(() async {
|
|
await Environment.initialize();
|
|
});
|
|
|
|
Finder editableTextAt(int index) => find.byType(EditableText).at(index);
|
|
|
|
testWidgets('사용자가 로그인 후 주요 화면을 탐색할 수 있다', (tester) async {
|
|
final view = tester.view;
|
|
view.physicalSize = const Size(1080, 720);
|
|
view.devicePixelRatio = 1.0;
|
|
addTearDown(() {
|
|
view.resetPhysicalSize();
|
|
view.resetDevicePixelRatio();
|
|
});
|
|
|
|
GetIt.I.registerSingleton<GroupRepository>(_StubGroupRepository());
|
|
GetIt.I.registerSingleton<GroupPermissionRepository>(
|
|
_StubGroupPermissionRepository(),
|
|
);
|
|
|
|
final router = _createTestRouter();
|
|
final catalog = createTestMenuCatalog();
|
|
await tester.pumpWidget(_TestApp(router: router, catalog: catalog));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Superport v2 로그인'), findsOneWidget);
|
|
|
|
await tester.enterText(editableTextAt(0), 'tester');
|
|
await tester.enterText(editableTextAt(1), 'password123');
|
|
await tester.tap(find.widgetWithText(ShadButton, '로그인'));
|
|
await tester.pump(const Duration(milliseconds: 650));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('테스트 대시보드'), findsOneWidget);
|
|
|
|
await tester.tap(find.widgetWithText(TextButton, '입고로 이동'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('입고 화면'), findsOneWidget);
|
|
|
|
await tester.tap(find.widgetWithText(TextButton, '대시보드로 돌아가기'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('테스트 대시보드'), findsOneWidget);
|
|
|
|
await tester.tap(find.widgetWithText(TextButton, '출고로 이동'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('출고 화면'), findsOneWidget);
|
|
|
|
await tester.tap(find.widgetWithText(TextButton, '대시보드로 돌아가기'));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.widgetWithText(TextButton, '대여로 이동'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('대여 화면'), findsOneWidget);
|
|
|
|
await tester.tap(find.widgetWithText(TextButton, '대시보드로 돌아가기'));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byTooltip('로그아웃'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Superport v2 로그인'), findsOneWidget);
|
|
});
|
|
}
|