import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.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_proceed_status.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/domain/usecases/approve_approval_use_case.dart'; import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart'; import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart'; import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart'; import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart'; void main() { const runFlow = bool.fromEnvironment('STAGING_RUN_APPROVAL_FLOW'); const useFakeFlow = bool.fromEnvironment('STAGING_USE_FAKE_APPROVAL_FLOW'); if (!runFlow) { testWidgets( 'approval flow e2e (환경 변수 설정 필요: STAGING_RUN_APPROVAL_FLOW=true)', (tester) async { tester.printToConsole( '통합 테스트를 실행하려면 STAGING_RUN_APPROVAL_FLOW=true 를 설정하세요.', ); }, skip: true, ); return; } IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final missingConfigs = []; if (!useFakeFlow) { missingConfigs.add('STAGING_USE_FAKE_APPROVAL_FLOW=true'); } if (missingConfigs.isNotEmpty) { testWidgets( 'approval flow e2e (환경 변수 설정 필요: ${missingConfigs.join(', ')})', (tester) async { tester.printToConsole( '결재 통합 테스트를 실행하려면 다음 변수를 설정하세요: ${missingConfigs.join(', ')}', ); }, skip: true, ); return; } testWidgets('inventory inbound → approval submit → approver hand-off', ( tester, ) async { const transactionTypeId = 501; const transactionStatusId = 10; const warehouseId = 30; const requesterId = 91; const firstApproverId = 201; const secondApproverId = 202; final stockRepository = _FakeStockTransactionRepository( transactionTypeId: transactionTypeId, initialStatusId: transactionStatusId, warehouseId: warehouseId, employeeId: requesterId, ); final approvalRepository = _FakeApprovalRepository(); final templateRepository = _FakeApprovalTemplateRepository(); final controller = ApprovalController( approvalRepository: approvalRepository, templateRepository: templateRepository, ); final approveUseCase = ApproveApprovalUseCase( repository: approvalRepository, ); final now = DateTime.now(); final transactionInput = StockTransactionCreateInput( transactionTypeId: transactionTypeId, transactionStatusId: transactionStatusId, warehouseId: warehouseId, transactionDate: now, createdById: requesterId, note: 'integration-test ${now.toIso8601String()}', lines: [ TransactionLineCreateInput( lineNo: 1, productId: 7001, quantity: 5, unitPrice: 1200, ), ], customers: [TransactionCustomerCreateInput(customerId: 4001)], approval: StockTransactionApprovalInput( requestedById: requesterId, steps: [ ApprovalStepAssignmentItem(stepOrder: 1, approverId: firstApproverId), ApprovalStepAssignmentItem( stepOrder: 2, approverId: secondApproverId, ), ], note: '입고 결재 테스트', ), ); final createdTransaction = await stockRepository.create(transactionInput); expect(createdTransaction.id, isNotNull); tester.printToConsole('created transaction: ${createdTransaction.id}'); final approvalSubmission = ApprovalSubmissionInput( transactionId: createdTransaction.id, statusId: 1, requesterId: requesterId, note: transactionInput.approval.note, steps: transactionInput.approval.steps, ); final submittedApproval = await approvalRepository.submit( approvalSubmission, ); expect(submittedApproval.id, isNotNull); tester.printToConsole('submitted approval: ${submittedApproval.id}'); await controller.fetch(); final approvals = controller.result?.items ?? const []; expect(approvals, isNotEmpty); expect(approvals.first.id, submittedApproval.id); await controller.selectApproval(submittedApproval.id!); expect(controller.selected?.currentStep?.stepOrder, 1); final firstFlow = await approveUseCase( ApprovalDecisionInput( approvalId: submittedApproval.id!, actorId: firstApproverId, ), ); expect(firstFlow.currentStep?.stepOrder, 2); tester.printToConsole('first approval completed, moved to step 2'); await controller.selectApproval(firstFlow.id!); expect(controller.selected?.currentStep?.stepOrder, 2); expect(controller.canProceedSelected, isTrue); final finalFlow = await approveUseCase( ApprovalDecisionInput( approvalId: firstFlow.id!, actorId: secondApproverId, ), ); expect(finalFlow.currentStep, isNull); expect(finalFlow.status.isTerminal, isTrue); tester.printToConsole('approval completed by final approver'); await controller.selectApproval(finalFlow.id!); expect(controller.selected?.status.isTerminal, isTrue); expect(controller.canProceedSelected, isFalse); }); } class _FakeStockTransactionRepository implements StockTransactionRepository { _FakeStockTransactionRepository({ required this.transactionTypeId, required this.initialStatusId, required this.warehouseId, required this.employeeId, }); final int transactionTypeId; final int initialStatusId; final int warehouseId; final int employeeId; int _sequence = 1; final Map _transactions = {}; @override Future create(StockTransactionCreateInput input) async { final id = _sequence++; final transaction = StockTransaction( id: id, transactionNo: 'TRX-${id.toString().padLeft(6, '0')}', transactionDate: input.transactionDate, type: StockTransactionType(id: transactionTypeId, name: '입고'), status: StockTransactionStatus(id: initialStatusId, name: '작성중'), warehouse: StockTransactionWarehouse( id: warehouseId, code: 'WH-$warehouseId', name: '테스트 창고', ), createdBy: StockTransactionEmployee( id: employeeId, employeeNo: 'EMP-$employeeId', name: '작성자', ), note: input.note, createdAt: DateTime.now(), updatedAt: DateTime.now(), lines: input.lines .map( (line) => StockTransactionLine( id: line.lineNo, lineNo: line.lineNo, product: StockTransactionProduct( id: line.productId, code: 'PRD-${line.productId}', name: '테스트 품목', ), quantity: line.quantity, unitPrice: line.unitPrice, note: line.note, ), ) .toList(growable: false), customers: input.customers .map( (customer) => StockTransactionCustomer( id: customer.customerId, customer: StockTransactionCustomerSummary( id: customer.customerId, code: 'CUST-${customer.customerId}', name: '거래처', ), note: customer.note, ), ) .toList(growable: false), ); _transactions[id] = transaction; return transaction; } @override Future submit(int id, {String? note}) async { final transaction = _require(id); final updated = transaction.copyWith( status: StockTransactionStatus(id: transaction.status.id, name: '제출'), updatedAt: DateTime.now(), ); _transactions[id] = updated; return updated; } StockTransaction _require(int id) { final transaction = _transactions[id]; if (transaction == null) { throw StateError('트랜잭션($id)을 찾을 수 없습니다.'); } return transaction; } @override Future> list({ StockTransactionListFilter? filter, }) async { return PaginatedResult( items: _transactions.values.toList(growable: false), page: 1, pageSize: _transactions.length, total: _transactions.length, ); } @override Future fetchDetail(int id, {List? include}) async { return _require(id); } @override Future update(int id, StockTransactionUpdateInput input) => throw UnimplementedError(); @override Future delete(int id) => throw UnimplementedError(); @override Future restore(int id) => throw UnimplementedError(); @override Future complete(int id, {String? note}) => throw UnimplementedError(); @override Future approve(int id, {String? note}) => throw UnimplementedError(); @override Future reject(int id, {String? note}) => throw UnimplementedError(); @override Future cancel(int id, {String? note}) => throw UnimplementedError(); } class _FakeApprovalRepository implements ApprovalRepository { int _sequence = 1000; final Map _approvals = {}; @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 { final items = _approvals.values.toList(growable: false); return PaginatedResult( items: items, page: 1, pageSize: items.length, total: items.length, ); } @override Future fetchDetail( int id, { bool includeSteps = true, bool includeHistories = true, }) async { final approval = _approvals[id]; if (approval == null) { throw StateError('결재($id)를 찾을 수 없습니다.'); } return approval; } @override Future submit(ApprovalSubmissionInput input) async { final id = _sequence++; final approvalNo = 'APP-${id.toString().padLeft(6, '0')}'; final status = ApprovalStatus(id: input.statusId, name: '진행중'); final requester = ApprovalRequester( id: input.requesterId, employeeNo: 'EMP-${input.requesterId}', name: '상신자', ); final steps = input.steps .map( (step) => ApprovalStep( id: step.stepOrder, stepOrder: step.stepOrder, approver: ApprovalApprover( id: step.approverId, employeeNo: 'EMP-${step.approverId}', name: '승인자 ${step.approverId}', ), status: ApprovalStatus(id: 1, name: '대기'), assignedAt: DateTime.now(), note: step.note, ), ) .toList(growable: false); final approval = Approval( id: id, approvalNo: approvalNo, transactionNo: input.transactionId != null ? 'TRX-${input.transactionId}' : 'TRX', status: status, requester: requester, requestedAt: DateTime.now(), note: input.note, steps: steps, histories: const [], currentStep: steps.isEmpty ? null : steps.first, ); _approvals[id] = approval; return approval; } @override Future approve(ApprovalDecisionInput input) async { final approval = await fetchDetail(input.approvalId); final current = approval.steps.firstWhere( (step) => step.decidedAt == null, orElse: () => throw StateError('모든 결재 단계가 이미 완료되었습니다.'), ); if (current.approver.id != input.actorId) { throw StateError('현재 단계 승인자가 아닙니다.'); } final steps = approval.steps .map((step) { if (step.decidedAt != null) { return step; } if (step.approver.id != input.actorId) { return step; } return step.copyWith( decidedAt: DateTime.now(), status: ApprovalStatus(id: 2, name: '승인됨', isTerminal: false), ); }) .toList(growable: false); final nextPending = steps.firstWhere( (step) => step.decidedAt == null, orElse: () => ApprovalStep( id: -1, stepOrder: -1, approver: ApprovalApprover( id: approval.requester.id, employeeNo: approval.requester.employeeNo, name: approval.requester.name, ), status: ApprovalStatus(id: 2, name: '완료', isTerminal: true), assignedAt: DateTime.now(), ), ); ApprovalStatus nextStatus; ApprovalStep? currentStep; if (nextPending.stepOrder == -1) { nextStatus = ApprovalStatus(id: 9, name: '승인 완료', isTerminal: true); currentStep = null; } else { nextStatus = approval.status; currentStep = steps.firstWhere((step) => step.decidedAt == null); } final updated = approval.copyWith( status: nextStatus, steps: steps, currentStep: currentStep, updatedAt: DateTime.now(), ); _approvals[input.approvalId] = updated; return updated; } @override Future canProceed(int id) async { final approval = await fetchDetail(id); final nextPending = approval.steps.firstWhere( (step) => step.decidedAt == null, orElse: () => ApprovalStep( id: -1, stepOrder: -1, approver: ApprovalApprover( id: approval.requester.id, employeeNo: approval.requester.employeeNo, name: approval.requester.name, ), status: approval.status, assignedAt: DateTime.now(), ), ); if (nextPending.stepOrder == -1) { return ApprovalProceedStatus( approvalId: id, canProceed: false, reason: '모든 단계가 완료되었습니다.', ); } return ApprovalProceedStatus( approvalId: id, canProceed: true, reason: null, ); } @override Future> listActions({bool activeOnly = true}) async { return [ ApprovalAction(id: 1, name: '승인', code: 'approve'), ApprovalAction(id: 2, name: '반려', code: 'reject'), ]; } @override Future resubmit(ApprovalResubmissionInput input) => throw UnimplementedError(); @override Future reject(ApprovalDecisionInput input) => throw UnimplementedError(); @override Future recall(ApprovalRecallInput input) => throw UnimplementedError(); @override Future> listHistory({ required int approvalId, int page = 1, int pageSize = 20, DateTime? from, DateTime? to, int? actorId, int? approvalActionId, }) => throw UnimplementedError(); @override Future performStepAction(ApprovalStepActionInput input) => throw UnimplementedError(); @override Future assignSteps(ApprovalStepAssignmentInput input) => throw UnimplementedError(); @override Future create(ApprovalCreateInput input) => throw UnimplementedError(); @override Future update(ApprovalUpdateInput input) => throw UnimplementedError(); @override Future delete(int id) => throw UnimplementedError(); @override Future restore(int id) => throw UnimplementedError(); } class _FakeApprovalTemplateRepository implements ApprovalTemplateRepository { @override Future> list({ int page = 1, int pageSize = 20, String? query, bool? isActive, }) async { return PaginatedResult( items: const [], page: 1, pageSize: 0, total: 0, ); } @override Future fetchDetail(int id, {bool includeSteps = true}) => throw UnimplementedError(); @override Future create( ApprovalTemplateInput input, { List steps = const [], }) => throw UnimplementedError(); @override Future update( int id, ApprovalTemplateInput input, { List? steps, }) => throw UnimplementedError(); @override Future delete(int id) => throw UnimplementedError(); @override Future restore(int id) => throw UnimplementedError(); }