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: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 'package:superport_v2/widgets/components/superport_detail_dialog.dart'; import '../../helpers/test_app.dart'; import '../../helpers/fixture_loader.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Map permissionFixture; late Set viewerPermissions; late Set approverPermissions; Set parseActions(String key) { final raw = permissionFixture[key]; if (raw is! List) { return {}; } return raw .whereType() .map( (action) => PermissionAction.values.firstWhere( (candidate) => candidate.name == action, orElse: () => PermissionAction.view, ), ) .toSet(); } Future selectDetailTab(WidgetTester tester, String id) async { final tabsFinder = find.descendant( of: find.byType(SuperportDetailDialog), matching: find.byType(ShadTabs), ); final tabsState = tester.state(tabsFinder); final controller = (tabsState as dynamic).controller as ShadTabsController; controller.select(id); await tester.pump(); await tester.pumpAndSettle(); } setUpAll(() async { dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true'); await Environment.initialize(); dotenv.env['FEATURE_APPROVALS_ENABLED'] = 'true'; permissionFixture = loadJsonFixture('approvals/approval_permissions.json'); viewerPermissions = parseActions('viewer'); approverPermissions = parseActions('approver'); }); tearDown(() async { await GetIt.I.reset(); }); Future pumpApprovalPage( WidgetTester tester, PermissionManager manager, ) async { await tester.pumpWidget( buildTestApp(const ApprovalPage(), permissionManager: manager), ); await tester.pump(const Duration(milliseconds: 200)); await tester.pumpAndSettle(); } testWidgets('결재 단계 액션은 승인 권한이 없으면 비활성화된다', (tester) async { final repo = _StubApprovalRepository(); final templateRepo = _StubApprovalTemplateRepository(); final lookupRepo = _StubInventoryLookupRepository(); GetIt.I.registerSingleton(repo); GetIt.I.registerSingleton(templateRepo); GetIt.I.registerSingleton(lookupRepo); final permissionManager = PermissionManager( overrides: {PermissionResources.approvals: viewerPermissions}, ); final view = tester.view; view.physicalSize = const Size(1280, 800); view.devicePixelRatio = 1.0; addTearDown(() { view.resetPhysicalSize(); view.resetDevicePixelRatio(); }); await pumpApprovalPage(tester, permissionManager); final rowFinder = find.byKey(const ValueKey('approval_row_1')); expect(rowFinder, findsOneWidget); await tester.tap(rowFinder); await tester.pumpAndSettle(); await selectDetailTab(tester, 'steps'); final approveButton = tester.widget( find.byKey(const ValueKey('step_action_100_approve')), ); expect(approveButton.onPressed, isNull); expect(find.text('결재 권한이 없어 단계 행위를 실행할 수 없습니다.'), findsOneWidget); }); testWidgets('승인 권한이 있으면 단계 액션을 실행할 수 있다', (tester) async { final repo = _StubApprovalRepository(); final templateRepo = _StubApprovalTemplateRepository(); final lookupRepo = _StubInventoryLookupRepository(); GetIt.I.registerSingleton(repo); GetIt.I.registerSingleton(templateRepo); GetIt.I.registerSingleton(lookupRepo); final permissionManager = PermissionManager( overrides: {PermissionResources.approvals: approverPermissions}, ); await pumpApprovalPage(tester, permissionManager); final rowFinder = find.byKey(const ValueKey('approval_row_1')); expect(rowFinder, findsOneWidget); await tester.tap(rowFinder); await tester.pumpAndSettle(); await selectDetailTab(tester, 'steps'); final approveButton = tester.widget( find.byKey(const ValueKey('step_action_100_approve')), ); 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(repo); GetIt.I.registerSingleton(templateRepo); GetIt.I.registerSingleton(lookupRepo); final permissionManager = PermissionManager( overrides: {PermissionResources.approvals: approverPermissions}, ); await pumpApprovalPage(tester, permissionManager); final rowFinder = find.byKey(const ValueKey('approval_row_1')); expect(rowFinder, findsOneWidget); await tester.tap(rowFinder); await tester.pumpAndSettle(); await selectDetailTab(tester, 'steps'); expect(find.textContaining('선행 단계가 완료되지 않았습니다.'), findsWidgets); final approveButton = tester.widget( find.byKey(const ValueKey('step_action_100_approve')), ); expect(approveButton.onPressed, isNull); }); } class _StubApprovalRepository implements ApprovalRepository { _StubApprovalRepository(); final ApprovalStatus _pendingStatus = ApprovalStatus(id: 1, name: '승인대기'); final ApprovalApprover _approver = ApprovalApprover( id: 10, employeeNo: 'E010', name: '김승인', ); late final ApprovalStep _step = ApprovalStep( id: 100, stepOrder: 1, approver: _approver, status: _pendingStatus, assignedAt: DateTime(2024, 1, 1), ); late final Approval _approval = Approval( id: 1, approvalNo: 'AP-001', transactionNo: 'TRX-001', status: _pendingStatus, currentStep: _step, requester: ApprovalRequester(id: 20, employeeNo: 'E020', name: '요청자'), requestedAt: DateTime(2024, 1, 1), steps: [_step], histories: const [], ); @override Future> list({ int page = 1, int pageSize = 20, int? transactionId, int? approvalStatusId, int? requestedById, List? statusCodes, bool includePending = false, bool includeHistories = false, bool includeSteps = false, }) async { return PaginatedResult( items: [_approval], page: 1, pageSize: 20, total: 1, ); } @override Future fetchDetail( int id, { bool includeSteps = true, bool includeHistories = true, }) async { return _approval; } @override Future submit(ApprovalSubmissionInput input) async { return _approval; } @override Future resubmit(ApprovalResubmissionInput input) async { return _approval; } @override Future approve(ApprovalDecisionInput input) async { return _approval; } @override Future reject(ApprovalDecisionInput input) async { return _approval; } @override Future recall(ApprovalRecallInput input) async { return _approval; } @override Future> listActions({bool activeOnly = true}) async { return [ ApprovalAction(id: 1, name: 'approve'), ApprovalAction(id: 2, name: 'reject'), ApprovalAction(id: 3, name: 'comment'), ]; } @override Future> listHistory({ required int approvalId, int page = 1, int pageSize = 20, DateTime? from, DateTime? to, int? actorId, int? approvalActionId, }) async { return PaginatedResult( items: const [], page: page, pageSize: pageSize, total: 0, ); } @override Future performStepAction(ApprovalStepActionInput input) async { return _approval; } @override Future assignSteps(ApprovalStepAssignmentInput input) async { return _approval; } @override Future canProceed(int id) async { return ApprovalProceedStatus(approvalId: id, canProceed: true); } @override Future create(ApprovalCreateInput input) { throw UnimplementedError(); } @override Future delete(int id) { throw UnimplementedError(); } @override Future restore(int id) { throw UnimplementedError(); } @override Future update(ApprovalUpdateInput input) { throw UnimplementedError(); } } class _BlockingApprovalRepository extends _StubApprovalRepository { @override Future canProceed(int id) async { return ApprovalProceedStatus( approvalId: id, canProceed: false, reason: '선행 단계가 완료되지 않았습니다. 관리자에게 문의하세요.', ); } } class _StubApprovalTemplateRepository implements ApprovalTemplateRepository { _StubApprovalTemplateRepository(); final ApprovalTemplate _template = ApprovalTemplate( id: 1, code: 'TMP-001', name: '표준 1단계', isActive: true, steps: [ ApprovalTemplateStep( stepOrder: 1, approver: ApprovalTemplateApprover( id: 10, employeeNo: 'E010', name: '김승인', ), ), ], ); @override Future> list({ int page = 1, int pageSize = 20, String? query, bool? isActive, }) async { return PaginatedResult( items: [_template], page: 1, pageSize: 20, total: 1, ); } @override Future fetchDetail( int id, { bool includeSteps = true, }) async { return _template; } @override Future create( ApprovalTemplateInput input, { List steps = const [], }) { throw UnimplementedError(); } @override Future delete(int id) { throw UnimplementedError(); } @override Future restore(int id) { throw UnimplementedError(); } @override Future update( int id, ApprovalTemplateInput input, { List? steps, }) { throw UnimplementedError(); } } class _StubInventoryLookupRepository implements InventoryLookupRepository { @override Future> fetchTransactionTypes({ bool activeOnly = true, }) async { return [ LookupItem(id: 1, name: '입고', code: 'INBOUND'), LookupItem(id: 2, name: '출고', code: 'OUTBOUND'), ]; } @override Future> fetchTransactionStatuses({ bool activeOnly = true, }) async { return [ LookupItem(id: 10, name: '승인대기', code: 'pending'), LookupItem(id: 11, name: '진행중', code: 'in_progress'), ]; } @override Future> fetchApprovalStatuses({ bool activeOnly = true, }) async { return [ LookupItem(id: 20, name: '승인대기', code: 'pending'), LookupItem(id: 21, name: '진행중', code: 'in_progress'), ]; } @override Future> fetchApprovalActions({ bool activeOnly = true, }) async { return [ LookupItem(id: 30, name: '승인', code: 'approve'), LookupItem(id: 31, name: '반려', code: 'reject'), ]; } }