결재 템플릿 단계 적용 구현

- ApprovalTemplate 엔티티·DTO·원격 리포지토리 추가
- ApprovalController에 템플릿 로딩/적용 상태와 assignSteps 호출 연동
- ApprovalPage 단계 탭에 템플릿 선택 UI 및 적용 확인 다이얼로그 구현
- 템플릿 적용 단위 테스트와 IMPLEMENTATION_TASKS 현황 갱신
This commit is contained in:
JiWoong Sul
2025-09-25 00:21:12 +09:00
parent b6e50464d2
commit c3010965ad
63 changed files with 10179 additions and 1436 deletions

View File

@@ -0,0 +1,240 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/common/models/paginated_result.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 'package:superport_v2/features/masters/group_permission/presentation/controllers/group_permission_controller.dart';
import 'package:superport_v2/features/masters/menu/domain/entities/menu.dart';
import 'package:superport_v2/features/masters/menu/domain/repositories/menu_repository.dart';
class _MockPermissionRepository extends Mock
implements GroupPermissionRepository {}
class _MockGroupRepository extends Mock implements GroupRepository {}
class _MockMenuRepository extends Mock implements MenuRepository {}
class _FakeGroupPermissionInput extends Fake implements GroupPermissionInput {}
void main() {
late GroupPermissionController controller;
late _MockPermissionRepository permissionRepository;
late _MockGroupRepository groupRepository;
late _MockMenuRepository menuRepository;
final samplePermission = GroupPermission(
id: 1,
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
menu: GroupPermissionMenu(id: 10, menuName: '대시보드'),
canCreate: true,
canRead: true,
canUpdate: false,
canDelete: false,
);
PaginatedResult<GroupPermission> createResult(List<GroupPermission> items) {
return PaginatedResult<GroupPermission>(
items: items,
page: 1,
pageSize: 20,
total: items.length,
);
}
setUpAll(() {
registerFallbackValue(_FakeGroupPermissionInput());
});
setUp(() {
permissionRepository = _MockPermissionRepository();
groupRepository = _MockGroupRepository();
menuRepository = _MockMenuRepository();
controller = GroupPermissionController(
permissionRepository: permissionRepository,
groupRepository: groupRepository,
menuRepository: menuRepository,
);
});
group('loadGroups/loadMenus', () {
test('그룹 목록을 로드한다', () async {
when(
() => groupRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isDefault: any(named: 'isDefault'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<Group>(
items: [Group(id: 1, groupName: '관리자')],
page: 1,
pageSize: 20,
total: 1,
),
);
await controller.loadGroups();
expect(controller.groups, isNotEmpty);
});
test('메뉴 목록을 로드한다', () async {
when(
() => menuRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
parentId: any(named: 'parentId'),
isActive: any(named: 'isActive'),
includeDeleted: any(named: 'includeDeleted'),
),
).thenAnswer(
(_) async => PaginatedResult<MenuItem>(
items: [MenuItem(id: 10, menuCode: 'MENU001', menuName: '대시보드')],
page: 1,
pageSize: 20,
total: 1,
),
);
await controller.loadMenus();
expect(controller.menus, isNotEmpty);
});
});
group('fetch', () {
setUp(() {
when(
() => permissionRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
groupId: any(named: 'groupId'),
menuId: any(named: 'menuId'),
isActive: any(named: 'isActive'),
includeDeleted: any(named: 'includeDeleted'),
),
).thenAnswer((_) async => createResult([samplePermission]));
});
test('정상 조회 시 데이터를 보관한다', () async {
await controller.fetch();
expect(controller.result?.items, isNotEmpty);
expect(controller.errorMessage, isNull);
});
test('필터 값을 전달한다', () async {
controller.updateGroupFilter(2);
controller.updateMenuFilter(5);
controller.updateStatusFilter(GroupPermissionStatusFilter.inactiveOnly);
controller.updateIncludeDeleted(true);
await controller.fetch(page: 3);
verify(
() => permissionRepository.list(
page: 3,
pageSize: 20,
groupId: 2,
menuId: 5,
isActive: false,
includeDeleted: true,
),
).called(1);
});
test('에러 발생 시 errorMessage에 저장', () async {
when(
() => permissionRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
groupId: any(named: 'groupId'),
menuId: any(named: 'menuId'),
isActive: any(named: 'isActive'),
includeDeleted: any(named: 'includeDeleted'),
),
).thenThrow(Exception('fail'));
await controller.fetch();
expect(controller.errorMessage, isNotNull);
});
});
test('필터 업데이트 메서드', () {
controller.updateGroupFilter(3);
controller.updateMenuFilter(7);
controller.updateStatusFilter(GroupPermissionStatusFilter.activeOnly);
controller.updateIncludeDeleted(true);
expect(controller.groupFilter, 3);
expect(controller.menuFilter, 7);
expect(controller.statusFilter, GroupPermissionStatusFilter.activeOnly);
expect(controller.includeDeleted, isTrue);
});
group('mutations', () {
setUp(() {
when(
() => permissionRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
groupId: any(named: 'groupId'),
menuId: any(named: 'menuId'),
isActive: any(named: 'isActive'),
includeDeleted: any(named: 'includeDeleted'),
),
).thenAnswer((_) async => createResult([samplePermission]));
});
final input = GroupPermissionInput(groupId: 1, menuId: 2);
test('create 성공', () async {
when(
() => permissionRepository.create(any()),
).thenAnswer((_) async => samplePermission);
final created = await controller.create(input);
expect(created, isNotNull);
verify(() => permissionRepository.create(any())).called(1);
});
test('update 성공', () async {
when(
() => permissionRepository.update(any(), any()),
).thenAnswer((_) async => samplePermission);
final updated = await controller.update(1, input);
expect(updated, isNotNull);
verify(() => permissionRepository.update(1, any())).called(1);
});
test('delete 성공', () async {
when(() => permissionRepository.delete(any())).thenAnswer((_) async {});
final result = await controller.delete(1);
expect(result, isTrue);
verify(() => permissionRepository.delete(1)).called(1);
});
test('restore 성공', () async {
when(
() => permissionRepository.restore(any()),
).thenAnswer((_) async => samplePermission);
final restored = await controller.restore(1);
expect(restored, isNotNull);
verify(() => permissionRepository.restore(1)).called(1);
});
});
}

View File

@@ -0,0 +1,285 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.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/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 'package:superport_v2/features/masters/group_permission/presentation/pages/group_permission_page.dart';
import 'package:superport_v2/features/masters/menu/domain/entities/menu.dart';
import 'package:superport_v2/features/masters/menu/domain/repositories/menu_repository.dart';
class _MockPermissionRepository extends Mock
implements GroupPermissionRepository {}
class _MockGroupRepository extends Mock implements GroupRepository {}
class _MockMenuRepository extends Mock implements MenuRepository {}
class _FakeGroupPermissionInput extends Fake implements GroupPermissionInput {}
Widget _buildApp(Widget child) {
return MaterialApp(
home: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
),
child: Scaffold(body: child),
),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
registerFallbackValue(_FakeGroupPermissionInput());
});
tearDown(() async {
await GetIt.I.reset();
dotenv.clean();
});
testWidgets('플래그 Off 시 스펙 화면 표시', (tester) async {
dotenv.testLoad(fileInput: 'FEATURE_GROUP_PERMISSIONS_ENABLED=false\n');
await tester.pumpWidget(_buildApp(const GroupPermissionPage()));
await tester.pump();
expect(find.text('그룹 권한 관리'), findsOneWidget);
expect(find.text('비활성화 (백엔드 준비 중)'), findsOneWidget);
});
group('플래그 On', () {
late _MockPermissionRepository permissionRepository;
late _MockGroupRepository groupRepository;
late _MockMenuRepository menuRepository;
setUp(() {
dotenv.testLoad(fileInput: 'FEATURE_GROUP_PERMISSIONS_ENABLED=true\n');
permissionRepository = _MockPermissionRepository();
groupRepository = _MockGroupRepository();
menuRepository = _MockMenuRepository();
GetIt.I.registerLazySingleton<GroupPermissionRepository>(
() => permissionRepository,
);
GetIt.I.registerLazySingleton<GroupRepository>(() => groupRepository);
GetIt.I.registerLazySingleton<MenuRepository>(() => menuRepository);
when(
() => groupRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isDefault: any(named: 'isDefault'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<Group>(
items: [Group(id: 1, groupName: '관리자')],
page: 1,
pageSize: 20,
total: 1,
),
);
when(
() => menuRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
parentId: any(named: 'parentId'),
isActive: any(named: 'isActive'),
includeDeleted: any(named: 'includeDeleted'),
),
).thenAnswer(
(_) async => PaginatedResult<MenuItem>(
items: [MenuItem(id: 10, menuCode: 'MENU001', menuName: '대시보드')],
page: 1,
pageSize: 20,
total: 1,
),
);
});
testWidgets('목록을 조회해 테이블에 렌더링한다', (tester) async {
when(
() => permissionRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
groupId: any(named: 'groupId'),
menuId: any(named: 'menuId'),
isActive: any(named: 'isActive'),
includeDeleted: any(named: 'includeDeleted'),
),
).thenAnswer(
(_) async => PaginatedResult<GroupPermission>(
items: [
GroupPermission(
id: 1,
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
menu: GroupPermissionMenu(id: 10, menuName: '대시보드'),
canCreate: true,
canRead: true,
),
],
page: 1,
pageSize: 20,
total: 1,
),
);
await tester.pumpWidget(_buildApp(const GroupPermissionPage()));
await tester.pumpAndSettle();
expect(find.text('대시보드'), findsOneWidget);
expect(find.text('관리자'), findsOneWidget);
});
testWidgets('신규 등록 폼 검증 메시지를 표시한다', (tester) async {
when(
() => permissionRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
groupId: any(named: 'groupId'),
menuId: any(named: 'menuId'),
isActive: any(named: 'isActive'),
includeDeleted: any(named: 'includeDeleted'),
),
).thenAnswer(
(_) async => PaginatedResult<GroupPermission>(
items: const [],
page: 1,
pageSize: 20,
total: 0,
),
);
await tester.pumpWidget(_buildApp(const GroupPermissionPage()));
await tester.pumpAndSettle();
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.pump();
expect(find.text('그룹을 선택하세요.'), findsOneWidget);
expect(find.text('메뉴를 선택하세요.'), findsOneWidget);
});
testWidgets('신규 등록 성공 시 repository.create 호출', (tester) async {
var listCall = 0;
when(
() => permissionRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
groupId: any(named: 'groupId'),
menuId: any(named: 'menuId'),
isActive: any(named: 'isActive'),
includeDeleted: any(named: 'includeDeleted'),
),
).thenAnswer((_) async {
listCall += 1;
if (listCall == 1) {
return PaginatedResult<GroupPermission>(
items: const [],
page: 1,
pageSize: 20,
total: 0,
);
}
return PaginatedResult<GroupPermission>(
items: [
GroupPermission(
id: 5,
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
menu: GroupPermissionMenu(id: 10, menuName: '대시보드'),
canCreate: true,
canRead: true,
),
],
page: 1,
pageSize: 20,
total: 1,
);
});
GroupPermissionInput? capturedInput;
when(() => permissionRepository.create(any())).thenAnswer((
invocation,
) async {
capturedInput =
invocation.positionalArguments.first as GroupPermissionInput;
return GroupPermission(
id: 5,
group: GroupPermissionGroup(
id: capturedInput!.groupId,
groupName: '관리자',
),
menu: GroupPermissionMenu(
id: capturedInput!.menuId,
menuName: '대시보드',
),
canCreate: capturedInput!.canCreate,
canRead: capturedInput!.canRead,
);
});
await tester.pumpWidget(_buildApp(const GroupPermissionPage()));
await tester.pumpAndSettle();
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
final dialog = find.byType(Dialog);
final selects = find.descendant(
of: dialog,
matching: find.byType(ShadSelect<int?>),
);
// 그룹 선택
await tester.tap(selects.at(0));
await tester.pumpAndSettle();
await tester.tap(find.text('관리자').last);
await tester.pumpAndSettle();
// 메뉴 선택
await tester.tap(selects.at(1));
await tester.pumpAndSettle();
await tester.tap(find.text('대시보드').last);
await tester.pumpAndSettle();
// 권한 체크 (생성, 수정, 삭제 on)
final switches = find.descendant(
of: dialog,
matching: find.byType(ShadSwitch),
);
await tester.tap(switches.at(0));
await tester.pump();
await tester.tap(switches.at(2));
await tester.pump();
await tester.tap(switches.at(3));
await tester.pump();
await tester.tap(find.text('등록'));
await tester.pumpAndSettle();
expect(capturedInput, isNotNull);
expect(capturedInput?.groupId, 1);
expect(capturedInput?.menuId, 10);
expect(capturedInput?.canCreate, isTrue);
expect(capturedInput?.canUpdate, isTrue);
expect(find.byType(Dialog), findsNothing);
expect(find.text('대시보드'), findsOneWidget);
verify(() => permissionRepository.create(any())).called(1);
});
});
}