feat: 결재·마스터 실연동 업데이트

This commit is contained in:
JiWoong Sul
2025-10-14 18:10:24 +09:00
parent 1325109fba
commit 8067416c09
66 changed files with 2129 additions and 222 deletions

View File

@@ -7,11 +7,15 @@ 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/permissions/permission_manager.dart';
import 'package:superport_v2/core/permissions/permission_resources.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart';
import 'package:superport_v2/features/approvals/presentation/pages/approval_page.dart';
import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart';
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../../helpers/test_app.dart';
@@ -42,12 +46,14 @@ void main() {
testWidgets('결재 단계 액션은 승인 권한이 없으면 비활성화된다', (tester) async {
final repo = _StubApprovalRepository();
final templateRepo = _StubApprovalTemplateRepository();
final lookupRepo = _StubInventoryLookupRepository();
GetIt.I.registerSingleton<ApprovalRepository>(repo);
GetIt.I.registerSingleton<ApprovalTemplateRepository>(templateRepo);
GetIt.I.registerSingleton<InventoryLookupRepository>(lookupRepo);
final permissionManager = PermissionManager(
overrides: {
'/approvals/requests': {PermissionAction.view},
PermissionResources.approvals: {PermissionAction.view},
},
);
@@ -83,12 +89,14 @@ void main() {
testWidgets('승인 권한이 있으면 단계 액션을 실행할 수 있다', (tester) async {
final repo = _StubApprovalRepository();
final templateRepo = _StubApprovalTemplateRepository();
final lookupRepo = _StubInventoryLookupRepository();
GetIt.I.registerSingleton<ApprovalRepository>(repo);
GetIt.I.registerSingleton<ApprovalTemplateRepository>(templateRepo);
GetIt.I.registerSingleton<InventoryLookupRepository>(lookupRepo);
final permissionManager = PermissionManager(
overrides: {
'/approvals/requests': {
PermissionResources.approvals: {
PermissionAction.view,
PermissionAction.approve,
},
@@ -115,6 +123,44 @@ void main() {
expect(approveButton.onPressed, isNotNull);
expect(find.text('결재 권한이 없어 단계 행위를 실행할 수 없습니다.'), findsNothing);
});
testWidgets('canProceed가 false면 단계 버튼을 비활성화하고 이유를 안내한다', (tester) async {
final repo = _BlockingApprovalRepository();
final templateRepo = _StubApprovalTemplateRepository();
final lookupRepo = _StubInventoryLookupRepository();
GetIt.I.registerSingleton<ApprovalRepository>(repo);
GetIt.I.registerSingleton<ApprovalTemplateRepository>(templateRepo);
GetIt.I.registerSingleton<InventoryLookupRepository>(lookupRepo);
final permissionManager = PermissionManager(
overrides: {
PermissionResources.approvals: {
PermissionAction.view,
PermissionAction.approve,
},
},
);
await pumpApprovalPage(tester, permissionManager);
final rowFinder = find.byKey(const ValueKey('approval_row_1'));
expect(rowFinder, findsOneWidget);
await tester.tap(rowFinder);
await tester.pumpAndSettle();
final tabContext = tester.element(find.byType(TabBar));
final tabController = DefaultTabController.of(tabContext);
tabController.animateTo(1);
await tester.pumpAndSettle();
expect(find.textContaining('선행 단계가 완료되지 않았습니다.'), findsWidgets);
final approveButton = tester.widget<ShadButton>(
find.byKey(const ValueKey('step_action_100_approve')),
);
expect(approveButton.onPressed, isNull);
});
}
class _StubApprovalRepository implements ApprovalRepository {
@@ -194,6 +240,11 @@ class _StubApprovalRepository implements ApprovalRepository {
return _approval;
}
@override
Future<ApprovalProceedStatus> canProceed(int id) async {
return ApprovalProceedStatus(approvalId: id, canProceed: true);
}
@override
Future<Approval> create(ApprovalInput input) {
throw UnimplementedError();
@@ -215,6 +266,17 @@ class _StubApprovalRepository implements ApprovalRepository {
}
}
class _BlockingApprovalRepository extends _StubApprovalRepository {
@override
Future<ApprovalProceedStatus> canProceed(int id) async {
return ApprovalProceedStatus(
approvalId: id,
canProceed: false,
reason: '선행 단계가 완료되지 않았습니다. 관리자에게 문의하세요.',
);
}
}
class _StubApprovalTemplateRepository implements ApprovalTemplateRepository {
_StubApprovalTemplateRepository();
@@ -285,3 +347,45 @@ class _StubApprovalTemplateRepository implements ApprovalTemplateRepository {
throw UnimplementedError();
}
}
class _StubInventoryLookupRepository implements InventoryLookupRepository {
@override
Future<List<LookupItem>> fetchTransactionTypes({
bool activeOnly = true,
}) async {
return <LookupItem>[
LookupItem(id: 1, name: '입고', code: 'INBOUND'),
LookupItem(id: 2, name: '출고', code: 'OUTBOUND'),
];
}
@override
Future<List<LookupItem>> fetchTransactionStatuses({
bool activeOnly = true,
}) async {
return <LookupItem>[
LookupItem(id: 10, name: '승인대기', code: 'pending'),
LookupItem(id: 11, name: '진행중', code: 'in_progress'),
];
}
@override
Future<List<LookupItem>> fetchApprovalStatuses({
bool activeOnly = true,
}) async {
return <LookupItem>[
LookupItem(id: 20, name: '승인대기', code: 'pending'),
LookupItem(id: 21, name: '진행중', code: 'in_progress'),
];
}
@override
Future<List<LookupItem>> fetchApprovalActions({
bool activeOnly = true,
}) async {
return <LookupItem>[
LookupItem(id: 30, name: '승인', code: 'approve'),
LookupItem(id: 31, name: '반려', code: 'reject'),
];
}
}

View File

@@ -4,6 +4,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart';
import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart';
@@ -74,6 +75,12 @@ void main() {
approvalRepository: repository,
templateRepository: templateRepository,
);
when(() => repository.canProceed(any())).thenAnswer(
(_) async => ApprovalProceedStatus(
approvalId: sampleApproval.id!,
canProceed: true,
),
);
});
// fetch 메서드 관련 시나리오
@@ -166,6 +173,8 @@ void main() {
includeHistories: true,
),
).called(1);
verify(() => repository.canProceed(1)).called(1);
expect(controller.canProceedSelected, isTrue);
});
test('에러 발생 시 errorMessage 설정', () async {
@@ -369,6 +378,30 @@ void main() {
expect(controller.isPerformingAction, isFalse);
});
test('canProceed가 false면 액션을 중단한다', () async {
when(() => repository.canProceed(any())).thenAnswer(
(_) async => ApprovalProceedStatus(
approvalId: sampleApproval.id!,
canProceed: false,
reason: '선행 단계가 완료되지 않았습니다.',
),
);
await controller.loadActionOptions(force: true);
await controller.fetch();
await controller.selectApproval(sampleApproval.id!);
final success = await controller.performStepAction(
step: sampleStep,
type: ApprovalStepActionType.approve,
);
expect(success, isFalse);
expect(controller.errorMessage, contains('선행 단계'));
expect(controller.canProceedSelected, isFalse);
verifyNever(() => repository.performStepAction(any()));
});
test('행위를 찾지 못하면 요청하지 않는다', () async {
when(
() => repository.listActions(activeOnly: any(named: 'activeOnly')),

View File

@@ -5,22 +5,38 @@ 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/core/permissions/permission_manager.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart';
import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart';
import 'package:superport_v2/features/approvals/presentation/pages/approval_page.dart';
import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart';
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
class _FakeApprovalInput extends Fake implements ApprovalInput {}
class _MockApprovalTemplateRepository extends Mock
implements ApprovalTemplateRepository {}
class _MockInventoryLookupRepository extends Mock
implements InventoryLookupRepository {}
Widget _buildApp(Widget child) {
return MaterialApp(
home: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
return PermissionScope(
manager: PermissionManager(),
child: MaterialApp(
home: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
),
child: Scaffold(body: child),
),
child: Scaffold(body: child),
),
);
}
@@ -49,11 +65,75 @@ void main() {
group('플래그 On', () {
late _MockApprovalRepository repository;
late _MockApprovalTemplateRepository templateRepository;
late _MockInventoryLookupRepository lookupRepository;
setUp(() {
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
repository = _MockApprovalRepository();
templateRepository = _MockApprovalTemplateRepository();
lookupRepository = _MockInventoryLookupRepository();
GetIt.I.registerLazySingleton<ApprovalRepository>(() => repository);
GetIt.I.registerLazySingleton<ApprovalTemplateRepository>(
() => templateRepository,
);
GetIt.I.registerLazySingleton<InventoryLookupRepository>(
() => lookupRepository,
);
when(
() => templateRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<ApprovalTemplate>(
items: [],
page: 1,
pageSize: 20,
total: 0,
),
);
when(
() => repository.listActions(activeOnly: any(named: 'activeOnly')),
).thenAnswer((_) async => const []);
when(() => lookupRepository.fetchApprovalStatuses()).thenAnswer(
(_) async => [LookupItem(id: 1, name: '승인대기', code: 'pending')],
);
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
status: any(named: 'status'),
from: any(named: 'from'),
to: any(named: 'to'),
includeHistories: any(named: 'includeHistories'),
includeSteps: any(named: 'includeSteps'),
),
).thenAnswer(
(_) async => PaginatedResult<Approval>(
items: const [],
page: 1,
pageSize: 20,
total: 0,
),
);
});
testWidgets('상태 룩업을 불러와 필터 라벨을 구성한다', (tester) async {
await tester.pumpWidget(_buildApp(const ApprovalPage()));
await tester.pumpAndSettle();
verify(() => lookupRepository.fetchApprovalStatuses()).called(1);
final statusSelectFinder = find.byKey(
const ValueKey(ApprovalStatusFilter.all),
);
expect(statusSelectFinder, findsOneWidget);
await tester.tap(statusSelectFinder);
await tester.pumpAndSettle();
expect(find.text('승인대기'), findsWidgets);
});
});
}

View File

@@ -7,6 +7,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/permissions/permission_manager.dart';
import 'package:superport_v2/core/permissions/permission_resources.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_input.dart';
import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_record.dart';
@@ -18,7 +19,9 @@ class _MockApprovalStepRepository extends Mock
Widget _buildApp(Widget child) {
final manager = PermissionManager(
overrides: {'/approvals/steps': PermissionAction.values.toSet()},
overrides: {
PermissionResources.approvalSteps: PermissionAction.values.toSet(),
},
);
return MaterialApp(
home: PermissionScope(

View File

@@ -0,0 +1,119 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/masters/customer/data/repositories/customer_repository_remote.dart';
class _MockApiClient extends Mock implements ApiClient {}
void main() {
late ApiClient apiClient;
late CustomerRepositoryRemote repository;
setUpAll(() {
registerFallbackValue(Options());
registerFallbackValue(CancelToken());
});
setUp(() {
apiClient = _MockApiClient();
repository = CustomerRepositoryRemote(apiClient: apiClient);
});
test('list 호출 시 필터를 쿼리에 포함한다', () async {
when(
() => apiClient.get<Map<String, dynamic>>(
any(),
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {
'items': [
{'id': 1, 'customer_code': 'C-001', 'customer_name': '슈퍼포트'},
],
'page': 2,
'page_size': 30,
'total': 1,
},
requestOptions: RequestOptions(path: '/api/v1/customers'),
statusCode: 200,
),
);
final result = await repository.list(
page: 2,
pageSize: 30,
query: 'sup',
isPartner: true,
isGeneral: false,
isActive: true,
);
expect(result.items, isNotEmpty);
final verification = verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
);
final path = verification.captured[0] as String;
final query = verification.captured[1] as Map<String, dynamic>;
expect(path, equals('/api/v1/customers'));
expect(query['page'], 2);
expect(query['page_size'], 30);
expect(query['q'], 'sup');
expect(query['is_partner'], true);
expect(query['is_general'], false);
expect(query['is_active'], true);
});
test('fetchDetail은 include=zipcode 파라미터를 전달한다', () async {
when(
() => apiClient.get<Map<String, dynamic>>(
any(),
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {
'data': {
'id': 10,
'customer_code': 'C-010',
'customer_name': '테스트 고객',
'zipcode': {'zipcode': '06000'},
},
},
requestOptions: RequestOptions(path: '/api/v1/customers/10'),
statusCode: 200,
),
);
final customer = await repository.fetchDetail(10);
expect(customer.customerCode, 'C-010');
final verification = verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
);
final path = verification.captured[0] as String;
final query = verification.captured[1] as Map<String, dynamic>;
expect(path, equals('/api/v1/customers/10'));
expect(query['include'], 'zipcode');
});
}

View File

@@ -0,0 +1,65 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/masters/group/data/repositories/group_repository_remote.dart';
class _MockApiClient extends Mock implements ApiClient {}
void main() {
late ApiClient apiClient;
late GroupRepositoryRemote repository;
setUpAll(() {
registerFallbackValue(Options());
registerFallbackValue(CancelToken());
});
setUp(() {
apiClient = _MockApiClient();
repository = GroupRepositoryRemote(apiClient: apiClient);
});
test('include 옵션이 쿼리 파라미터에 반영된다', () async {
when(
() => apiClient.get<Map<String, dynamic>>(
any(),
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {
'items': [
{
'id': 1,
'group_name': '관리자',
'is_default': true,
'is_active': true,
},
],
},
requestOptions: RequestOptions(path: '/api/v1/groups'),
statusCode: 200,
),
);
await repository.list(includePermissions: true, includeEmployees: true);
final captured = verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured;
final path = captured[0] as String;
final query = captured[1] as Map<String, dynamic>;
expect(path, equals('/api/v1/groups'));
expect(query['include'], 'permissions,employees');
});
}

View File

@@ -2,12 +2,18 @@ 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/core/permissions/permission_manager.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/presentation/controllers/group_controller.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';
class _MockGroupRepository extends Mock implements GroupRepository {}
class _MockGroupPermissionRepository extends Mock
implements GroupPermissionRepository {}
class _FakeGroupInput extends Fake implements GroupInput {}
void main() {
@@ -126,6 +132,65 @@ void main() {
expect(controller.statusFilter, GroupStatusFilter.activeOnly);
});
group('permission sync', () {
late _MockGroupPermissionRepository permissionRepository;
late PermissionManager permissionManager;
setUp(() {
permissionRepository = _MockGroupPermissionRepository();
permissionManager = PermissionManager();
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 <GroupPermission>[],
page: 1,
pageSize: 200,
total: 0,
),
);
});
test('그룹 생성 후 권한 동기화를 시도한다', () async {
when(() => repository.create(any())).thenAnswer((_) async => sampleGroup);
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isDefault: any(named: 'isDefault'),
isActive: any(named: 'isActive'),
),
).thenAnswer((_) async => createResult());
final controllerWithSync = GroupController(
repository: repository,
permissionRepository: permissionRepository,
permissionManager: permissionManager,
);
await controllerWithSync.create(GroupInput(groupName: '신규 그룹'));
verify(
() => permissionRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
groupId: sampleGroup.id,
menuId: any(named: 'menuId'),
isActive: any(named: 'isActive'),
includeDeleted: any(named: 'includeDeleted'),
),
).called(greaterThanOrEqualTo(1));
});
});
group('mutations', () {
setUp(() {
when(

View File

@@ -0,0 +1,115 @@
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/core/permissions/permission_manager.dart';
import 'package:superport_v2/core/permissions/permission_resources.dart';
import 'package:superport_v2/features/masters/group_permission/application/permission_synchronizer.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';
class _MockGroupPermissionRepository extends Mock
implements GroupPermissionRepository {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('그룹 권한을 모두 불러와 PermissionManager에 적용한다', () async {
final repository = _MockGroupPermissionRepository();
final manager = PermissionManager();
final synchronizer = PermissionSynchronizer(
repository: repository,
manager: manager,
pageSize: 1,
);
final permissionPage1 = GroupPermission(
id: 1,
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
menu: GroupPermissionMenu(
id: 10,
menuCode: 'INBOUND',
menuName: '입고',
path: '/inventory/inbound',
),
canCreate: true,
canRead: true,
canUpdate: false,
canDelete: false,
);
final permissionPage2 = GroupPermission(
id: 2,
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
menu: GroupPermissionMenu(
id: 11,
menuCode: 'OUTBOUND',
menuName: '출고',
path: '/inventory/outbound',
),
canCreate: false,
canRead: true,
canUpdate: true,
canDelete: false,
);
when(
() => repository.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((invocation) async {
final page = invocation.namedArguments[#page] as int;
if (page == 1) {
return PaginatedResult<GroupPermission>(
items: [permissionPage1],
page: 1,
pageSize: 1,
total: 2,
);
}
return PaginatedResult<GroupPermission>(
items: [permissionPage2],
page: 2,
pageSize: 1,
total: 2,
);
});
await synchronizer.syncForGroup(1);
verify(
() => repository.list(
page: any(named: 'page'),
pageSize: 1,
groupId: 1,
menuId: null,
isActive: true,
includeDeleted: false,
),
).called(greaterThanOrEqualTo(1));
expect(
manager.can(
PermissionResources.stockTransactions,
PermissionAction.create,
),
isTrue,
);
expect(
manager.can(PermissionResources.stockTransactions, PermissionAction.edit),
isTrue,
);
expect(
manager.can(
PermissionResources.stockTransactions,
PermissionAction.delete,
),
isFalse,
);
});
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport_v2/core/permissions/permission_manager.dart';
import 'package:superport_v2/core/permissions/permission_resources.dart';
import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart';
import 'package:superport_v2/features/masters/group_permission/domain/mappers/group_permission_mapper.dart';
void main() {
test('메뉴 경로 기준으로 권한 맵을 생성한다', () {
final permissions = [
GroupPermission(
id: 1,
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
menu: GroupPermissionMenu(
id: 10,
menuCode: 'INBOUND',
menuName: '입고',
path: '/inventory/inbound',
),
canCreate: true,
canRead: true,
canUpdate: false,
canDelete: false,
),
GroupPermission(
id: 2,
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
menu: GroupPermissionMenu(
id: 11,
menuCode: 'OUTBOUND',
menuName: '출고',
path: '/inventory/outbound',
),
canCreate: false,
canRead: true,
canUpdate: true,
canDelete: true,
),
GroupPermission(
id: 3,
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
menu: GroupPermissionMenu(
id: 12,
menuCode: 'NO_PATH',
menuName: '경로없음',
path: null,
),
canCreate: true,
canRead: true,
canUpdate: true,
canDelete: true,
),
];
final map = buildPermissionMap(permissions);
expect(map.length, 1);
final stockPermissions =
map[PermissionResources.stockTransactions] ?? <PermissionAction>{};
expect(
stockPermissions,
containsAll(<PermissionAction>{
PermissionAction.view,
PermissionAction.create,
PermissionAction.edit,
PermissionAction.delete,
}),
);
expect(map.containsKey('NO_PATH'), isFalse);
});
}

View File

@@ -2,6 +2,7 @@ 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/core/permissions/permission_manager.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';
@@ -24,11 +25,17 @@ void main() {
late _MockPermissionRepository permissionRepository;
late _MockGroupRepository groupRepository;
late _MockMenuRepository menuRepository;
late PermissionManager permissionManager;
final samplePermission = GroupPermission(
id: 1,
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
menu: GroupPermissionMenu(id: 10, menuName: '대시보드'),
menu: GroupPermissionMenu(
id: 10,
menuCode: 'DASHBOARD',
menuName: '대시보드',
path: '/dashboard',
),
canCreate: true,
canRead: true,
canUpdate: false,
@@ -52,10 +59,22 @@ void main() {
permissionRepository = _MockPermissionRepository();
groupRepository = _MockGroupRepository();
menuRepository = _MockMenuRepository();
permissionManager = PermissionManager();
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]));
controller = GroupPermissionController(
permissionRepository: permissionRepository,
groupRepository: groupRepository,
menuRepository: menuRepository,
permissionManager: permissionManager,
);
});
@@ -68,6 +87,8 @@ void main() {
query: any(named: 'query'),
isDefault: any(named: 'isDefault'),
isActive: any(named: 'isActive'),
includePermissions: any(named: 'includePermissions'),
includeEmployees: any(named: 'includeEmployees'),
),
).thenAnswer(
(_) async => PaginatedResult<Group>(

View File

@@ -6,6 +6,7 @@ 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/permissions/permission_manager.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';
@@ -24,13 +25,16 @@ 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,
return PermissionScope(
manager: PermissionManager(),
child: MaterialApp(
home: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
),
child: Scaffold(body: child),
),
child: Scaffold(body: child),
),
);
}
@@ -80,6 +84,8 @@ void main() {
query: any(named: 'query'),
isDefault: any(named: 'isDefault'),
isActive: any(named: 'isActive'),
includePermissions: any(named: 'includePermissions'),
includeEmployees: any(named: 'includeEmployees'),
),
).thenAnswer(
(_) async => PaginatedResult<Group>(
@@ -125,7 +131,12 @@ void main() {
GroupPermission(
id: 1,
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
menu: GroupPermissionMenu(id: 10, menuName: '대시보드'),
menu: GroupPermissionMenu(
id: 10,
menuCode: 'DASHBOARD',
menuName: '대시보드',
path: '/dashboard',
),
canCreate: true,
canRead: true,
),
@@ -201,7 +212,12 @@ void main() {
GroupPermission(
id: 5,
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
menu: GroupPermissionMenu(id: 10, menuName: '대시보드'),
menu: GroupPermissionMenu(
id: 10,
menuCode: 'DASHBOARD',
menuName: '대시보드',
path: '/dashboard',
),
canCreate: true,
canRead: true,
),
@@ -226,7 +242,9 @@ void main() {
),
menu: GroupPermissionMenu(
id: capturedInput!.menuId,
menuCode: 'DASHBOARD',
menuName: '대시보드',
path: '/dashboard',
),
canCreate: capturedInput!.canCreate,
canRead: capturedInput!.canRead,

View File

@@ -0,0 +1,86 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/masters/product/data/repositories/product_repository_remote.dart';
class _MockApiClient extends Mock implements ApiClient {}
void main() {
late ApiClient apiClient;
late ProductRepositoryRemote repository;
setUpAll(() {
registerFallbackValue(Options());
registerFallbackValue(CancelToken());
});
setUp(() {
apiClient = _MockApiClient();
repository = ProductRepositoryRemote(apiClient: apiClient);
});
test('list는 include 파라미터와 필터를 전달한다', () async {
when(
() => apiClient.get<Map<String, dynamic>>(
any(),
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {
'items': [
{
'id': 1,
'product_code': 'P-001',
'product_name': '샘플',
'vendor': {
'id': 10,
'vendor_code': 'V-010',
'vendor_name': '테스트 벤더',
},
'uom': {'id': 5, 'uom_name': 'EA'},
},
],
'page': 1,
'page_size': 20,
'total': 1,
},
requestOptions: RequestOptions(path: '/api/v1/products'),
statusCode: 200,
),
);
final result = await repository.list(
page: 3,
pageSize: 40,
query: 'gear',
vendorId: 10,
uomId: 5,
isActive: false,
);
expect(result.items, isNotEmpty);
final verification = verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
);
final query = verification.captured[1] as Map<String, dynamic>;
expect(query['include'], 'vendor,uom');
expect(query['vendor_id'], 10);
expect(query['uom_id'], 5);
expect(query['is_active'], false);
expect(query['page'], 3);
expect(query['page_size'], 40);
expect(query['q'], 'gear');
});
}

View File

@@ -154,6 +154,33 @@ void main() {
).called(1);
});
testWidgets('목록이 비어 있으면 안내 문구를 표시한다', (tester) async {
when(
() => productRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
vendorId: any(named: 'vendorId'),
uomId: any(named: 'uomId'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<Product>(
items: const [],
page: 1,
pageSize: 20,
total: 0,
),
);
await tester.pumpWidget(
_buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))),
);
await tester.pumpAndSettle();
expect(find.text('조건에 맞는 제품이 없습니다.'), findsOneWidget);
});
testWidgets('폼 검증: 필수값 미입력 시 에러 메시지를 표시한다', (tester) async {
when(
() => productRepository.list(

View File

@@ -0,0 +1,65 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/masters/user/data/repositories/user_repository_remote.dart';
class _MockApiClient extends Mock implements ApiClient {}
void main() {
late ApiClient apiClient;
late UserRepositoryRemote repository;
setUpAll(() {
registerFallbackValue(Options());
registerFallbackValue(CancelToken());
});
setUp(() {
apiClient = _MockApiClient();
repository = UserRepositoryRemote(apiClient: apiClient);
});
test('목록 조회 시 include=group 파라미터를 전달한다', () async {
when(
() => apiClient.get<Map<String, dynamic>>(
any(),
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {
'items': [
{
'id': 1,
'employee_no': 'E-001',
'employee_name': '홍길동',
'group': {'id': 2, 'group_name': '관리자'},
},
],
},
requestOptions: RequestOptions(path: '/api/v1/employees'),
statusCode: 200,
),
);
await repository.list();
final captured = verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured;
final path = captured[0] as String;
final query = captured[1] as Map<String, dynamic>;
expect(path, equals('/api/v1/employees'));
expect(query['include'], 'group');
});
}

View File

@@ -2,8 +2,11 @@ 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/core/permissions/permission_manager.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/user/domain/entities/user.dart';
import 'package:superport_v2/features/masters/user/domain/repositories/user_repository.dart';
import 'package:superport_v2/features/masters/user/presentation/controllers/user_controller.dart';
@@ -12,12 +15,17 @@ class _MockUserRepository extends Mock implements UserRepository {}
class _MockGroupRepository extends Mock implements GroupRepository {}
class _MockGroupPermissionRepository extends Mock
implements GroupPermissionRepository {}
class _FakeUserInput extends Fake implements UserInput {}
void main() {
late UserController controller;
late _MockUserRepository userRepository;
late _MockGroupRepository groupRepository;
late _MockGroupPermissionRepository permissionRepository;
late PermissionManager permissionManager;
final sampleUser = UserAccount(
id: 1,
@@ -44,9 +52,43 @@ void main() {
setUp(() {
userRepository = _MockUserRepository();
groupRepository = _MockGroupRepository();
permissionRepository = _MockGroupPermissionRepository();
permissionManager = PermissionManager();
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,
menuCode: 'DASHBOARD',
menuName: '대시보드',
path: '/dashboard',
),
canCreate: true,
canRead: true,
),
],
page: 1,
pageSize: 200,
total: 1,
),
);
controller = UserController(
userRepository: userRepository,
groupRepository: groupRepository,
permissionRepository: permissionRepository,
permissionManager: permissionManager,
);
});
@@ -58,6 +100,8 @@ void main() {
query: any(named: 'query'),
isDefault: any(named: 'isDefault'),
isActive: any(named: 'isActive'),
includePermissions: any(named: 'includePermissions'),
includeEmployees: any(named: 'includeEmployees'),
),
).thenAnswer(
(_) async => PaginatedResult<Group>(
@@ -82,6 +126,8 @@ void main() {
query: any(named: 'query'),
isDefault: any(named: 'isDefault'),
isActive: any(named: 'isActive'),
includePermissions: any(named: 'includePermissions'),
includeEmployees: any(named: 'includeEmployees'),
),
).thenAnswer(
(_) async => PaginatedResult<Group>(
@@ -195,6 +241,24 @@ void main() {
verify(() => userRepository.delete(1)).called(1);
});
test('delete 이후 권한 동기화를 시도한다', () async {
when(() => userRepository.delete(any())).thenAnswer((_) async {});
await controller.fetch();
await controller.delete(sampleUser.id!);
verify(
() => permissionRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
groupId: sampleUser.group!.id,
menuId: any(named: 'menuId'),
isActive: any(named: 'isActive'),
includeDeleted: any(named: 'includeDeleted'),
),
).called(greaterThanOrEqualTo(1));
});
test('restore 성공', () async {
when(
() => userRepository.restore(any()),

View File

@@ -6,8 +6,11 @@ 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/permissions/permission_manager.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/user/domain/entities/user.dart';
import 'package:superport_v2/features/masters/user/domain/repositories/user_repository.dart';
import 'package:superport_v2/features/masters/user/presentation/pages/user_page.dart';
@@ -16,16 +19,22 @@ class _MockUserRepository extends Mock implements UserRepository {}
class _MockGroupRepository extends Mock implements GroupRepository {}
class _MockGroupPermissionRepository extends Mock
implements GroupPermissionRepository {}
class _FakeUserInput extends Fake implements UserInput {}
Widget _buildApp(Widget child) {
return MaterialApp(
home: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
return PermissionScope(
manager: PermissionManager(),
child: MaterialApp(
home: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
),
child: Scaffold(body: child),
),
child: Scaffold(body: child),
),
);
}
@@ -55,12 +64,17 @@ void main() {
group('플래그 On', () {
late _MockUserRepository userRepository;
late _MockGroupRepository groupRepository;
late _MockGroupPermissionRepository permissionRepository;
setUp(() {
dotenv.testLoad(fileInput: 'FEATURE_USERS_ENABLED=true\n');
userRepository = _MockUserRepository();
groupRepository = _MockGroupRepository();
permissionRepository = _MockGroupPermissionRepository();
GetIt.I.registerLazySingleton<UserRepository>(() => userRepository);
GetIt.I.registerLazySingleton<GroupRepository>(() => groupRepository);
GetIt.I.registerLazySingleton<GroupPermissionRepository>(
() => permissionRepository,
);
when(
() => groupRepository.list(
@@ -69,6 +83,8 @@ void main() {
query: any(named: 'query'),
isDefault: any(named: 'isDefault'),
isActive: any(named: 'isActive'),
includePermissions: any(named: 'includePermissions'),
includeEmployees: any(named: 'includeEmployees'),
),
).thenAnswer(
(_) async => PaginatedResult<Group>(
@@ -78,6 +94,37 @@ void main() {
total: 1,
),
);
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,
menuCode: 'DASHBOARD',
menuName: '대시보드',
path: '/dashboard',
),
canCreate: true,
canRead: true,
),
],
page: 1,
pageSize: 200,
total: 1,
),
);
});
testWidgets('목록 조회 후 테이블 렌더', (tester) async {

View File

@@ -84,6 +84,35 @@ void main() {
).called(1);
});
testWidgets('목록이 비어 있으면 안내 문구를 표시한다', (tester) async {
dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=true\n');
final repository = _MockVendorRepository();
GetIt.I.registerLazySingleton<VendorRepository>(() => repository);
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<Vendor>(
items: const [],
page: 1,
pageSize: 20,
total: 0,
),
);
await tester.pumpWidget(
_buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))),
);
await tester.pumpAndSettle();
expect(find.text('조건에 맞는 벤더가 없습니다.'), findsOneWidget);
});
testWidgets('신규 등록 폼에서 필수값 미입력 시 검증 메시지를 보여준다', (tester) async {
dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=true\n');
final repository = _MockVendorRepository();

View File

@@ -0,0 +1,60 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart';
class _MockApiClient extends Mock implements ApiClient {}
void main() {
late ApiClient apiClient;
late WarehouseRepositoryRemote repository;
setUpAll(() {
registerFallbackValue(Options());
registerFallbackValue(CancelToken());
});
setUp(() {
apiClient = _MockApiClient();
repository = WarehouseRepositoryRemote(apiClient: apiClient);
});
test('include=zipcode 파라미터를 기본으로 전달한다', () async {
when(
() => apiClient.get<Map<String, dynamic>>(
any(),
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {
'items': [
{'id': 1, 'warehouse_code': 'WH-001', 'warehouse_name': '1센터'},
],
},
requestOptions: RequestOptions(path: '/api/v1/warehouses'),
statusCode: 200,
),
);
await repository.list();
final captured = verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured;
final path = captured[0] as String;
final query = captured[1] as Map<String, dynamic>;
expect(path, equals('/api/v1/warehouses'));
expect(query['include'], 'zipcode');
});
}

View File

@@ -48,6 +48,7 @@ void main() {
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
includeZipcode: any(named: 'includeZipcode'),
),
).thenAnswer((_) async => createResult());
@@ -55,8 +56,13 @@ void main() {
expect(controller.result?.items, isNotEmpty);
verify(
() =>
repository.list(page: 1, pageSize: 20, query: null, isActive: null),
() => repository.list(
page: 1,
pageSize: 20,
query: null,
isActive: null,
includeZipcode: true,
),
).called(1);
});
@@ -67,6 +73,7 @@ void main() {
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
includeZipcode: any(named: 'includeZipcode'),
),
).thenThrow(Exception('fail'));
@@ -92,6 +99,7 @@ void main() {
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
includeZipcode: any(named: 'includeZipcode'),
),
).thenAnswer((_) async => createResult());
});

View File

@@ -92,6 +92,7 @@ void main() {
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
includeZipcode: any(named: 'includeZipcode'),
),
).thenAnswer(
(_) async => PaginatedResult<Warehouse>(
@@ -116,8 +117,13 @@ void main() {
expect(find.text('WH-001'), findsOneWidget);
verify(
() =>
repository.list(page: 1, pageSize: 20, query: null, isActive: null),
() => repository.list(
page: 1,
pageSize: 20,
query: null,
isActive: null,
includeZipcode: true,
),
).called(1);
});
@@ -128,6 +134,7 @@ void main() {
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
includeZipcode: any(named: 'includeZipcode'),
),
).thenAnswer(
(_) async => PaginatedResult<Warehouse>(

View File

@@ -0,0 +1,77 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/util/postal_search/data/repositories/postal_search_repository_remote.dart';
class _MockApiClient extends Mock implements ApiClient {}
void main() {
late ApiClient apiClient;
late PostalSearchRepositoryRemote repository;
setUpAll(() {
registerFallbackValue(Options());
registerFallbackValue(CancelToken());
});
setUp(() {
apiClient = _MockApiClient();
repository = PostalSearchRepositoryRemote(apiClient: apiClient);
});
test('검색 키워드가 비어 있으면 빈 배열을 반환한다', () async {
final result = await repository.search(keyword: ' ');
expect(result, isEmpty);
verifyNever(() => apiClient.get<dynamic>(any()));
});
test('검색 요청 시 q/page/page_size 파라미터를 전달한다', () async {
when(
() => apiClient.get<dynamic>(
any(),
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<dynamic>(
data: {
'items': [
{
'zipcode': '06000',
'sido': '서울특별시',
'sigungu': '강남구',
'road_name': '테헤란로',
},
],
},
requestOptions: RequestOptions(path: '/api/v1/zipcodes'),
statusCode: 200,
),
);
final result = await repository.search(keyword: '테헤란로', limit: 10, page: 2);
expect(result, hasLength(1));
final verification = verify(
() => apiClient.get<dynamic>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
);
final path = verification.captured[0] as String;
final query = verification.captured[1] as Map<String, dynamic>;
expect(path, equals('/api/v1/zipcodes'));
expect(query['q'], '테헤란로');
expect(query['page'], 2);
expect(query['page_size'], 10);
expect(query.containsKey('zipcode'), isFalse);
expect(query.containsKey('road_name'), isFalse);
});
}

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/network/api_error.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart';
import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart';
@@ -151,4 +153,32 @@ void main() {
await tester.tap(find.text('닫기'));
await tester.pumpAndSettle();
});
testWidgets('검색 실패 시 Failure 메시지를 표시한다', (tester) async {
final exception = ApiException(
code: ApiErrorCode.unprocessableEntity,
message: '우편번호 검색에 실패했습니다.',
details: {
'errors': {
'keyword': ['검색어를 다시 확인해 주세요.'],
},
},
);
await tester.pumpWidget(
_PostalSearchHarness(
fetcher: (_) => Future<List<PostalSearchResult>>.error(exception),
),
);
await tester.pumpAndSettle();
final inputFinder = find.byType(EditableText);
await tester.enterText(inputFinder, '강남대로');
await tester.tap(find.text('검색'));
await tester.pumpAndSettle();
final failure = Failure.from(exception);
expect(find.text(failure.describe()), findsOneWidget);
});
}

View File

@@ -1,12 +1,18 @@
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: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/constants/app_sections.dart';
import 'package:superport_v2/features/login/presentation/pages/login_page.dart';
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
import 'package:superport_v2/core/permissions/permission_manager.dart';
import 'package:superport_v2/core/theme/superport_shad_theme.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';
GoRouter _createTestRouter() {
return GoRouter(
@@ -114,6 +120,108 @@ class _PlaceholderPage extends StatelessWidget {
}
}
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,
int? menuId,
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();
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -132,6 +240,13 @@ void main() {
view.resetDevicePixelRatio();
});
await GetIt.I.reset();
GetIt.I.registerSingleton<GroupRepository>(_StubGroupRepository());
GetIt.I.registerSingleton<GroupPermissionRepository>(
_StubGroupPermissionRepository(),
);
addTearDown(() async => GetIt.I.reset());
final router = _createTestRouter();
await tester.pumpWidget(_TestApp(router: router));
await tester.pumpAndSettle();