feat(approvals): Approval Flow v2 프런트엔드 전면 개편
- 환경/라우터 모듈에 approval_flow_v2 토글을 추가하고 FeatureFlags 초기화를 연결 (.env*, lib/core/**) - ApiClient 빌더·ApiRoutes 확장과 ApprovalRepositoryRemote 리팩터링으로 include·액션 시그니처를 정합화 - ApprovalFlow·ApprovalDraft 엔티티/레포/유즈케이스를 도입해 서버 초안과 단계 액션(승인·회수·재상신)을 지원 - Approval 컨트롤러·히스토리·템플릿 페이지와 공유 위젯을 재작성해 감사 로그·회수 UX·템플릿 CRUD를 반영 - Inbound/Outbound/Rental 컨트롤러·페이지에 결재 섹션을 삽입하고 대시보드 pending 카드 요약을 갱신 - SuperportDialog·FormField 등 공통 위젯을 보강하고 승인 위젯 가이드를 추가해 UI 가이드를 정리 - 결재/재고 테스트 픽스처와 단위·위젯·통합 테스트를 확장하고 flutter_test_config로 스테이징 호스트를 허용 - Approval Flow 레포트/플랜 문서를 업데이트하고 ApprovalFlow_System_Integration_and_ChangePlan.md를 추가 - 실행: flutter analyze, flutter test
This commit is contained in:
@@ -2,7 +2,12 @@ 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/network/failure.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_flow.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/recall_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/resubmit_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/entities/approval_history_record.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/history/presentation/controllers/approval_history_controller.dart';
|
||||
@@ -10,16 +15,27 @@ import 'package:superport_v2/features/approvals/history/presentation/controllers
|
||||
class _MockApprovalHistoryRepository extends Mock
|
||||
implements ApprovalHistoryRepository {}
|
||||
|
||||
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
|
||||
|
||||
class _MockRecallApprovalUseCase extends Mock
|
||||
implements RecallApprovalUseCase {}
|
||||
|
||||
class _MockResubmitApprovalUseCase extends Mock
|
||||
implements ResubmitApprovalUseCase {}
|
||||
|
||||
void main() {
|
||||
late ApprovalHistoryController controller;
|
||||
late _MockApprovalHistoryRepository repository;
|
||||
late _MockApprovalRepository approvalRepository;
|
||||
late _MockRecallApprovalUseCase recallUseCase;
|
||||
late _MockResubmitApprovalUseCase resubmitUseCase;
|
||||
|
||||
final record = ApprovalHistoryRecord(
|
||||
id: 1,
|
||||
approvalId: 10,
|
||||
approvalNo: 'APP-2024-0001',
|
||||
stepOrder: 1,
|
||||
action: ApprovalAction(id: 11, name: 'approve'),
|
||||
action: ApprovalAction(id: 11, name: 'approve', code: 'approve'),
|
||||
fromStatus: ApprovalStatus(id: 1, name: '대기', color: null),
|
||||
toStatus: ApprovalStatus(id: 2, name: '승인', color: null),
|
||||
approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'),
|
||||
@@ -27,6 +43,16 @@ void main() {
|
||||
note: '승인 완료',
|
||||
);
|
||||
|
||||
final auditEntry = ApprovalHistory(
|
||||
id: 2,
|
||||
action: ApprovalAction(id: 12, name: 'recall', code: 'recall'),
|
||||
fromStatus: ApprovalStatus(id: 2, name: '승인'),
|
||||
toStatus: ApprovalStatus(id: 3, name: '회수'),
|
||||
approver: ApprovalApprover(id: 22, employeeNo: 'E002', name: '박회수'),
|
||||
actionAt: DateTime(2024, 4, 2, 10),
|
||||
note: '요청자 회수',
|
||||
);
|
||||
|
||||
PaginatedResult<ApprovalHistoryRecord> createResult(
|
||||
List<ApprovalHistoryRecord> items,
|
||||
) {
|
||||
@@ -38,9 +64,49 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
PaginatedResult<ApprovalHistory> createAuditResult(
|
||||
List<ApprovalHistory> items,
|
||||
) {
|
||||
return PaginatedResult<ApprovalHistory>(
|
||||
items: items,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: items.length,
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalFlow createFlow(int approvalId) {
|
||||
final status = ApprovalStatus(id: 5, name: '대기');
|
||||
final requester = ApprovalRequester(
|
||||
id: 88,
|
||||
employeeNo: 'EMP088',
|
||||
name: '상신자',
|
||||
);
|
||||
final approval = Approval(
|
||||
id: approvalId,
|
||||
approvalNo: 'APP-$approvalId',
|
||||
transactionId: approvalId * 1000,
|
||||
transactionNo: 'TRX-$approvalId',
|
||||
status: status,
|
||||
requester: requester,
|
||||
requestedAt: DateTime(2024, 4, 1),
|
||||
transactionUpdatedAt: DateTime(2024, 4, 1, 12),
|
||||
steps: const [],
|
||||
);
|
||||
return ApprovalFlow(approval: approval);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
repository = _MockApprovalHistoryRepository();
|
||||
controller = ApprovalHistoryController(repository: repository);
|
||||
approvalRepository = _MockApprovalRepository();
|
||||
recallUseCase = _MockRecallApprovalUseCase();
|
||||
resubmitUseCase = _MockResubmitApprovalUseCase();
|
||||
controller = ApprovalHistoryController(
|
||||
repository: repository,
|
||||
approvalRepository: approvalRepository,
|
||||
recallUseCase: recallUseCase,
|
||||
resubmitUseCase: resubmitUseCase,
|
||||
);
|
||||
});
|
||||
|
||||
test('fetch 성공 시 결과를 갱신한다', () async {
|
||||
@@ -131,4 +197,186 @@ void main() {
|
||||
expect(controller.from, isNull);
|
||||
expect(controller.to, isNull);
|
||||
});
|
||||
|
||||
test('fetchAuditLogs는 ApprovalRepository를 사용한다', () async {
|
||||
when(
|
||||
() => approvalRepository.listHistory(
|
||||
approvalId: any(named: 'approvalId'),
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
actorId: any(named: 'actorId'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
),
|
||||
).thenAnswer((_) async => createAuditResult([auditEntry]));
|
||||
|
||||
await controller.fetchAuditLogs(approvalId: 10);
|
||||
|
||||
expect(controller.auditResult?.items, isNotEmpty);
|
||||
expect(controller.selectedApprovalId, 10);
|
||||
expect(controller.auditPageSize, 10);
|
||||
});
|
||||
|
||||
test('updateActiveTab이 탭 상태를 변경한다', () {
|
||||
expect(controller.activeTab, ApprovalHistoryTab.flow);
|
||||
controller.updateActiveTab(ApprovalHistoryTab.audit);
|
||||
expect(controller.activeTab, ApprovalHistoryTab.audit);
|
||||
});
|
||||
|
||||
test('clearAuditSelection은 감사 로그 상태를 초기화한다', () async {
|
||||
when(
|
||||
() => approvalRepository.listHistory(
|
||||
approvalId: any(named: 'approvalId'),
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
actorId: any(named: 'actorId'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
),
|
||||
).thenAnswer((_) async => createAuditResult([auditEntry]));
|
||||
|
||||
await controller.fetchAuditLogs(approvalId: 10);
|
||||
controller.clearAuditSelection();
|
||||
|
||||
expect(controller.selectedApprovalId, isNull);
|
||||
expect(controller.auditResult, isNull);
|
||||
});
|
||||
|
||||
test('recallApproval은 유즈케이스를 호출하고 목록을 새로고침한다', () async {
|
||||
final recallInput = ApprovalRecallInput(approvalId: 10, actorId: 77);
|
||||
when(
|
||||
() => recallUseCase.call(recallInput),
|
||||
).thenAnswer((_) async => createFlow(10));
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
action: any(named: 'action'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
).thenAnswer((_) async => createResult([record]));
|
||||
when(
|
||||
() => approvalRepository.listHistory(
|
||||
approvalId: any(named: 'approvalId'),
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
actorId: any(named: 'actorId'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
),
|
||||
).thenAnswer((_) async => createAuditResult([auditEntry]));
|
||||
|
||||
await controller.fetchAuditLogs(approvalId: 10);
|
||||
final flow = await controller.recallApproval(recallInput);
|
||||
|
||||
expect(flow, isNotNull);
|
||||
expect(controller.isPerformingAction, isFalse);
|
||||
verify(() => recallUseCase.call(recallInput)).called(1);
|
||||
verify(
|
||||
() => repository.list(
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
query: null,
|
||||
action: null,
|
||||
from: null,
|
||||
to: null,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
verify(
|
||||
() => approvalRepository.listHistory(
|
||||
approvalId: 10,
|
||||
page: 1,
|
||||
pageSize: any(named: 'pageSize'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
actorId: null,
|
||||
approvalActionId: null,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
});
|
||||
|
||||
test('resubmitApproval은 유즈케이스를 호출한다', () async {
|
||||
final submission = ApprovalSubmissionInput(
|
||||
statusId: 3,
|
||||
requesterId: 77,
|
||||
steps: const [],
|
||||
);
|
||||
final resubmitInput = ApprovalResubmissionInput(
|
||||
approvalId: 11,
|
||||
actorId: 77,
|
||||
submission: submission,
|
||||
);
|
||||
when(
|
||||
() => resubmitUseCase.call(resubmitInput),
|
||||
).thenAnswer((_) async => createFlow(11));
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
action: any(named: 'action'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
).thenAnswer((_) async => createResult([record]));
|
||||
when(
|
||||
() => approvalRepository.listHistory(
|
||||
approvalId: any(named: 'approvalId'),
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
actorId: any(named: 'actorId'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
),
|
||||
).thenAnswer((_) async => createAuditResult([auditEntry]));
|
||||
|
||||
await controller.fetchAuditLogs(approvalId: 11);
|
||||
final flow = await controller.resubmitApproval(resubmitInput);
|
||||
|
||||
expect(flow, isNotNull);
|
||||
expect(controller.isPerformingAction, isFalse);
|
||||
verify(() => resubmitUseCase.call(resubmitInput)).called(1);
|
||||
});
|
||||
|
||||
test('loadApprovalFlow는 403 응답 시 선택을 금지한다', () async {
|
||||
when(
|
||||
() => approvalRepository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenThrow(const Failure(message: '접근이 거부되었습니다.', statusCode: 403));
|
||||
|
||||
await controller.loadApprovalFlow(77);
|
||||
|
||||
expect(controller.isSelectionForbidden, isTrue);
|
||||
expect(controller.selectedFlow, isNull);
|
||||
expect(controller.errorMessage, contains('접근이 거부'));
|
||||
});
|
||||
|
||||
test('fetchAuditLogs는 403 응답 시 선택을 차단한다', () async {
|
||||
when(
|
||||
() => approvalRepository.listHistory(
|
||||
approvalId: any(named: 'approvalId'),
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
actorId: any(named: 'actorId'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
),
|
||||
).thenThrow(const Failure(message: '접근 불가', statusCode: 403));
|
||||
|
||||
await controller.fetchAuditLogs(approvalId: 12);
|
||||
|
||||
expect(controller.isSelectionForbidden, isTrue);
|
||||
expect(controller.auditResult, isNull);
|
||||
expect(controller.errorMessage, contains('접근 불가'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,13 +7,32 @@ import 'package:shadcn_ui/shadcn_ui.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_flow.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/entities/approval_history_record.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/history/presentation/pages/approval_history_page.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/recall_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/resubmit_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/auth/application/auth_service.dart';
|
||||
import 'package:superport_v2/features/auth/domain/entities/auth_permission.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/widgets/components/superport_table.dart';
|
||||
|
||||
class _MockApprovalHistoryRepository extends Mock
|
||||
implements ApprovalHistoryRepository {}
|
||||
|
||||
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
|
||||
|
||||
class _MockRecallApprovalUseCase extends Mock
|
||||
implements RecallApprovalUseCase {}
|
||||
|
||||
class _MockResubmitApprovalUseCase extends Mock
|
||||
implements ResubmitApprovalUseCase {}
|
||||
|
||||
class _MockAuthService extends Mock implements AuthService {}
|
||||
|
||||
Widget _buildApp(Widget child) {
|
||||
return MaterialApp(
|
||||
home: ShadTheme(
|
||||
@@ -29,14 +48,33 @@ Widget _buildApp(Widget child) {
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late _MockApprovalHistoryRepository repository;
|
||||
late _MockApprovalHistoryRepository historyRepository;
|
||||
late _MockApprovalRepository approvalRepository;
|
||||
late _MockRecallApprovalUseCase recallUseCase;
|
||||
late _MockResubmitApprovalUseCase resubmitUseCase;
|
||||
late _MockAuthService authService;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(ApprovalRecallInput(approvalId: 0, actorId: 0));
|
||||
registerFallbackValue(
|
||||
ApprovalResubmissionInput(
|
||||
approvalId: 0,
|
||||
actorId: 0,
|
||||
submission: ApprovalSubmissionInput(
|
||||
statusId: 0,
|
||||
requesterId: 0,
|
||||
steps: const [],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
final record = ApprovalHistoryRecord(
|
||||
id: 1,
|
||||
approvalId: 10,
|
||||
approvalNo: 'APP-2024-0001',
|
||||
stepOrder: 1,
|
||||
action: ApprovalAction(id: 11, name: 'approve'),
|
||||
action: ApprovalAction(id: 11, name: 'approve', code: 'approve'),
|
||||
fromStatus: ApprovalStatus(id: 1, name: '대기', color: null),
|
||||
toStatus: ApprovalStatus(id: 2, name: '승인', color: null),
|
||||
approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'),
|
||||
@@ -44,6 +82,160 @@ void main() {
|
||||
note: '승인 완료',
|
||||
);
|
||||
|
||||
final secondRecord = ApprovalHistoryRecord(
|
||||
id: 2,
|
||||
approvalId: 11,
|
||||
approvalNo: 'APP-2024-0002',
|
||||
stepOrder: 2,
|
||||
action: ApprovalAction(id: 12, name: 'submit', code: 'submit'),
|
||||
fromStatus: ApprovalStatus(id: 1, name: '대기', color: null),
|
||||
toStatus: ApprovalStatus(id: 3, name: '진행중', color: null),
|
||||
approver: ApprovalApprover(id: 31, employeeNo: 'E031', name: '초기승인'),
|
||||
actionAt: DateTime(2024, 4, 2, 9, 30),
|
||||
note: '상신 완료',
|
||||
);
|
||||
|
||||
ApprovalFlow stubFlow() {
|
||||
final approval = Approval(
|
||||
transactionId: 1,
|
||||
approvalNo: 'APP-2024-0001',
|
||||
transactionNo: 'TRX-001',
|
||||
status: ApprovalStatus(id: 2, name: '승인'),
|
||||
requester: ApprovalRequester(id: 99, employeeNo: 'E099', name: '테스터'),
|
||||
requestedAt: DateTime(2024, 4, 1),
|
||||
steps: const [],
|
||||
histories: const [],
|
||||
transactionUpdatedAt: DateTime(2024, 4, 1, 12),
|
||||
);
|
||||
return ApprovalFlow(approval: approval);
|
||||
}
|
||||
|
||||
ApprovalFlow recallableFlow() {
|
||||
final status = ApprovalStatus(id: 2, name: '진행중', isTerminal: false);
|
||||
final approver = ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인');
|
||||
final steps = [
|
||||
ApprovalStep(
|
||||
id: 100,
|
||||
stepOrder: 1,
|
||||
approver: approver,
|
||||
status: status,
|
||||
assignedAt: DateTime(2024, 4, 1, 9),
|
||||
decidedAt: null,
|
||||
),
|
||||
ApprovalStep(
|
||||
id: 101,
|
||||
stepOrder: 2,
|
||||
approver: approver,
|
||||
status: status,
|
||||
assignedAt: DateTime(2024, 4, 1, 10),
|
||||
decidedAt: DateTime(2024, 4, 1, 10, 30),
|
||||
),
|
||||
];
|
||||
final approval = Approval(
|
||||
id: 10,
|
||||
approvalNo: 'APP-2024-0001',
|
||||
transactionId: 10,
|
||||
transactionNo: 'TRX-001',
|
||||
status: status,
|
||||
requester: ApprovalRequester(id: 99, employeeNo: 'E099', name: '테스터'),
|
||||
requestedAt: DateTime(2024, 4, 1),
|
||||
steps: steps,
|
||||
histories: const [],
|
||||
updatedAt: DateTime(2024, 4, 1, 12),
|
||||
transactionUpdatedAt: DateTime(2024, 4, 1, 11, 50),
|
||||
);
|
||||
return ApprovalFlow(approval: approval);
|
||||
}
|
||||
|
||||
// ignore: unused_element
|
||||
ApprovalFlow resubmittableFlow() {
|
||||
final pendingStatus = ApprovalStatus(id: 2, name: '대기', isTerminal: false);
|
||||
final rejectedStatus = ApprovalStatus(id: 5, name: '반려', isTerminal: true);
|
||||
final firstApprover = ApprovalApprover(
|
||||
id: 31,
|
||||
employeeNo: 'E002',
|
||||
name: '1차 승인자',
|
||||
);
|
||||
final finalApprover = ApprovalApprover(
|
||||
id: 32,
|
||||
employeeNo: 'E003',
|
||||
name: '최종 승인자',
|
||||
);
|
||||
final steps = [
|
||||
ApprovalStep(
|
||||
id: 120,
|
||||
stepOrder: 1,
|
||||
approver: firstApprover,
|
||||
status: pendingStatus,
|
||||
assignedAt: DateTime(2024, 3, 30, 9),
|
||||
decidedAt: DateTime(2024, 3, 30, 10),
|
||||
),
|
||||
ApprovalStep(
|
||||
id: 121,
|
||||
stepOrder: 2,
|
||||
approver: finalApprover,
|
||||
status: rejectedStatus,
|
||||
assignedAt: DateTime(2024, 3, 30, 11),
|
||||
decidedAt: DateTime(2024, 3, 30, 12),
|
||||
note: '보완 필요',
|
||||
),
|
||||
];
|
||||
final approval = Approval(
|
||||
id: 10,
|
||||
approvalNo: 'APP-2024-0001',
|
||||
transactionId: 11,
|
||||
transactionNo: 'TRX-001',
|
||||
status: rejectedStatus,
|
||||
requester: ApprovalRequester(id: 99, employeeNo: 'E099', name: '테스터'),
|
||||
requestedAt: DateTime(2024, 3, 30),
|
||||
decidedAt: DateTime(2024, 3, 30, 12, 30),
|
||||
note: '반려 사유 공유',
|
||||
steps: steps,
|
||||
histories: const [],
|
||||
updatedAt: DateTime(2024, 3, 30, 12, 30),
|
||||
transactionUpdatedAt: DateTime(2024, 3, 30, 12),
|
||||
);
|
||||
return ApprovalFlow(approval: approval);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
historyRepository = _MockApprovalHistoryRepository();
|
||||
approvalRepository = _MockApprovalRepository();
|
||||
recallUseCase = _MockRecallApprovalUseCase();
|
||||
resubmitUseCase = _MockResubmitApprovalUseCase();
|
||||
authService = _MockAuthService();
|
||||
|
||||
final sl = GetIt.I;
|
||||
sl.registerLazySingleton<ApprovalHistoryRepository>(
|
||||
() => historyRepository,
|
||||
);
|
||||
sl.registerLazySingleton<ApprovalRepository>(() => approvalRepository);
|
||||
sl.registerLazySingleton<RecallApprovalUseCase>(() => recallUseCase);
|
||||
sl.registerLazySingleton<ResubmitApprovalUseCase>(() => resubmitUseCase);
|
||||
sl.registerLazySingleton<AuthService>(() => authService);
|
||||
|
||||
when(
|
||||
() => approvalRepository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenAnswer((_) async => stubFlow().approval);
|
||||
|
||||
when(() => authService.session).thenReturn(
|
||||
AuthSession(
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
user: const AuthenticatedUser(id: 99, name: '테스터', employeeNo: 'E099'),
|
||||
permissions: const <AuthPermission>[],
|
||||
),
|
||||
);
|
||||
|
||||
when(() => recallUseCase.call(any())).thenAnswer((_) async => stubFlow());
|
||||
when(() => resubmitUseCase.call(any())).thenAnswer((_) async => stubFlow());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await GetIt.I.reset();
|
||||
dotenv.clean();
|
||||
@@ -61,11 +253,53 @@ void main() {
|
||||
|
||||
testWidgets('이력 목록을 렌더링하고 검색 필터를 적용한다', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
|
||||
repository = _MockApprovalHistoryRepository();
|
||||
GetIt.I.registerLazySingleton<ApprovalHistoryRepository>(() => repository);
|
||||
|
||||
when(
|
||||
() => repository.list(
|
||||
() => historyRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
action: any(named: 'action'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => PaginatedResult<ApprovalHistoryRecord>(
|
||||
items: [record, secondRecord],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 2,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ApprovalHistoryPage()));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('APP-2024-0001'), findsOneWidget);
|
||||
expect(find.text('승인 완료'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(ShadInput).first, 'APP-2024');
|
||||
await tester.tap(find.text('검색 적용'));
|
||||
await tester.pump();
|
||||
|
||||
verify(
|
||||
() => historyRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: 20,
|
||||
query: 'APP-2024',
|
||||
action: null,
|
||||
from: null,
|
||||
to: null,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
});
|
||||
|
||||
testWidgets('회수 시 상세 재조회 실패 안내를 노출한다', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
|
||||
|
||||
when(
|
||||
() => historyRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
@@ -82,26 +316,45 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final recallable = recallableFlow();
|
||||
var fetchCount = 0;
|
||||
when(
|
||||
() => approvalRepository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenAnswer((_) async {
|
||||
if (fetchCount == 0) {
|
||||
fetchCount++;
|
||||
return recallable.approval;
|
||||
}
|
||||
fetchCount++;
|
||||
throw Exception('refresh failed');
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ApprovalHistoryPage()));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('APP-2024-0001'), findsOneWidget);
|
||||
expect(find.text('승인 완료'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(ShadInput).first, 'APP-2024');
|
||||
await tester.tap(find.text('검색 적용'));
|
||||
final table = tester.widget<SuperportTable>(find.byType(SuperportTable));
|
||||
table.onRowTap?.call(0);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: 20,
|
||||
query: 'APP-2024',
|
||||
action: null,
|
||||
from: null,
|
||||
to: null,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
final recallButton = find.widgetWithText(ShadButton, '회수').first;
|
||||
await tester.ensureVisible(recallButton);
|
||||
await tester.tap(recallButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('결재 회수'), findsOneWidget);
|
||||
final confirmButton = find.widgetWithText(ShadButton, '회수').last;
|
||||
await tester.ensureVisible(confirmButton);
|
||||
await tester.tap(confirmButton);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(fetchCount, equals(2));
|
||||
expect(find.text('결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_flow.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/usecases/recall_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/resubmit_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/history/presentation/controllers/approval_history_controller.dart';
|
||||
|
||||
class _MockApprovalHistoryRepository extends Mock
|
||||
implements ApprovalHistoryRepository {}
|
||||
|
||||
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
|
||||
|
||||
class _MockRecallApprovalUseCase extends Mock
|
||||
implements RecallApprovalUseCase {}
|
||||
|
||||
class _MockResubmitApprovalUseCase extends Mock
|
||||
implements ResubmitApprovalUseCase {}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late ApprovalHistoryRepository historyRepository;
|
||||
late ApprovalRepository approvalRepository;
|
||||
late RecallApprovalUseCase recallUseCase;
|
||||
late ResubmitApprovalUseCase resubmitUseCase;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(ApprovalRecallInput(approvalId: 0, actorId: 0));
|
||||
registerFallbackValue(
|
||||
ApprovalResubmissionInput(
|
||||
approvalId: 0,
|
||||
actorId: 0,
|
||||
submission: ApprovalSubmissionInput(statusId: 0, requesterId: 0),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
historyRepository = _MockApprovalHistoryRepository();
|
||||
approvalRepository = _MockApprovalRepository();
|
||||
recallUseCase = _MockRecallApprovalUseCase();
|
||||
resubmitUseCase = _MockResubmitApprovalUseCase();
|
||||
});
|
||||
|
||||
ApprovalFlow buildRecallableFlow() {
|
||||
final status = ApprovalStatus(id: 1, name: '진행중');
|
||||
final steps = [
|
||||
ApprovalStep(
|
||||
id: 1,
|
||||
stepOrder: 1,
|
||||
approver: ApprovalApprover(id: 101, employeeNo: 'E101', name: '1차'),
|
||||
status: status,
|
||||
assignedAt: DateTime(2024, 4, 1, 9),
|
||||
),
|
||||
ApprovalStep(
|
||||
id: 2,
|
||||
stepOrder: 2,
|
||||
approver: ApprovalApprover(id: 102, employeeNo: 'E102', name: '2차'),
|
||||
status: status,
|
||||
assignedAt: DateTime(2024, 4, 1, 10),
|
||||
decidedAt: DateTime(2024, 4, 1, 10, 30),
|
||||
),
|
||||
];
|
||||
final approval = Approval(
|
||||
id: 500,
|
||||
approvalNo: 'APP-500',
|
||||
transactionId: 5000,
|
||||
transactionNo: 'TRX-500',
|
||||
status: status,
|
||||
requester: ApprovalRequester(id: 90, employeeNo: 'E090', name: '상신자'),
|
||||
requestedAt: DateTime(2024, 4, 1),
|
||||
steps: steps,
|
||||
histories: const [],
|
||||
updatedAt: DateTime(2024, 4, 1, 12),
|
||||
currentStep: steps.first,
|
||||
transactionUpdatedAt: DateTime(2024, 4, 1, 11, 30),
|
||||
);
|
||||
return ApprovalFlow(approval: approval);
|
||||
}
|
||||
|
||||
ApprovalFlow buildResubmittableFlow() {
|
||||
final rejectedStatus = ApprovalStatus(id: 9, name: '반려', isTerminal: true);
|
||||
final steps = [
|
||||
ApprovalStep(
|
||||
id: 1,
|
||||
stepOrder: 1,
|
||||
approver: ApprovalApprover(id: 101, employeeNo: 'E101', name: '1차'),
|
||||
status: rejectedStatus,
|
||||
assignedAt: DateTime(2024, 3, 30, 9),
|
||||
decidedAt: DateTime(2024, 3, 30, 9, 30),
|
||||
),
|
||||
ApprovalStep(
|
||||
id: 2,
|
||||
stepOrder: 2,
|
||||
approver: ApprovalApprover(id: 102, employeeNo: 'E102', name: '최종'),
|
||||
status: rejectedStatus,
|
||||
assignedAt: DateTime(2024, 3, 30, 10),
|
||||
decidedAt: DateTime(2024, 3, 30, 10, 30),
|
||||
note: '보완 필요',
|
||||
),
|
||||
];
|
||||
final approval = Approval(
|
||||
id: 600,
|
||||
approvalNo: 'APP-600',
|
||||
transactionId: 6000,
|
||||
transactionNo: 'TRX-600',
|
||||
status: rejectedStatus,
|
||||
requester: ApprovalRequester(id: 90, employeeNo: 'E090', name: '상신자'),
|
||||
requestedAt: DateTime(2024, 3, 30),
|
||||
decidedAt: DateTime(2024, 3, 30, 12),
|
||||
note: '반려 메모',
|
||||
steps: steps,
|
||||
histories: const [],
|
||||
updatedAt: DateTime(2024, 3, 30, 12),
|
||||
currentStep: null,
|
||||
transactionUpdatedAt: DateTime(2024, 3, 30, 11, 45),
|
||||
);
|
||||
return ApprovalFlow(approval: approval);
|
||||
}
|
||||
|
||||
Future<ApprovalHistoryController> buildController(ApprovalFlow flow) async {
|
||||
final controller = ApprovalHistoryController(
|
||||
repository: historyRepository,
|
||||
approvalRepository: approvalRepository,
|
||||
recallUseCase: recallUseCase,
|
||||
resubmitUseCase: resubmitUseCase,
|
||||
);
|
||||
|
||||
when(
|
||||
() => approvalRepository.fetchDetail(
|
||||
flow.id!,
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenAnswer((_) async => flow.approval);
|
||||
when(() => approvalRepository.canProceed(flow.id!)).thenAnswer(
|
||||
(_) async => ApprovalProceedStatus(
|
||||
approvalId: flow.id!,
|
||||
canProceed: flow.currentStep != null,
|
||||
),
|
||||
);
|
||||
|
||||
await controller.loadApprovalFlow(flow.id!);
|
||||
return controller;
|
||||
}
|
||||
|
||||
testWidgets('회수 버튼이 노트를 전달해 유즈케이스를 호출한다', (tester) async {
|
||||
final flow = buildRecallableFlow();
|
||||
final controller = await buildController(flow);
|
||||
|
||||
ApprovalRecallInput? captured;
|
||||
when(() => recallUseCase.call(any())).thenAnswer((invocation) async {
|
||||
captured = invocation.positionalArguments.first as ApprovalRecallInput;
|
||||
return flow;
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
_ApprovalActionHarness(controller: controller, actorId: 99),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('recall-note-field')),
|
||||
' 긴급 회수 ',
|
||||
);
|
||||
await tester.tap(find.text('회수'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(captured, isNotNull);
|
||||
expect(captured?.approvalId, flow.id);
|
||||
expect(captured?.actorId, 99);
|
||||
expect(captured?.note, '긴급 회수');
|
||||
expect(captured?.transactionExpectedUpdatedAt, flow.transactionUpdatedAt);
|
||||
});
|
||||
|
||||
testWidgets('재상신 버튼이 템플릿 단계를 포함해 유즈케이스를 호출한다', (tester) async {
|
||||
final flow = buildResubmittableFlow();
|
||||
final controller = await buildController(flow);
|
||||
|
||||
ApprovalResubmissionInput? captured;
|
||||
when(() => resubmitUseCase.call(any())).thenAnswer((invocation) async {
|
||||
captured =
|
||||
invocation.positionalArguments.first as ApprovalResubmissionInput;
|
||||
return flow;
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
_ApprovalActionHarness(controller: controller, actorId: 99),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('resubmit-note-field')),
|
||||
' 재상신 메모 ',
|
||||
);
|
||||
await tester.tap(find.text('재상신'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(captured, isNotNull);
|
||||
expect(captured?.actorId, 99);
|
||||
expect(captured?.note, '재상신 메모');
|
||||
expect(captured?.submission.steps.length, flow.steps.length);
|
||||
expect(captured?.submission.requesterId, flow.requester.id);
|
||||
expect(captured?.submission.transactionId, flow.transactionId);
|
||||
expect(captured?.transactionExpectedUpdatedAt, flow.transactionUpdatedAt);
|
||||
});
|
||||
}
|
||||
|
||||
class _ApprovalActionHarness extends StatefulWidget {
|
||||
const _ApprovalActionHarness({
|
||||
required this.controller,
|
||||
required this.actorId,
|
||||
});
|
||||
|
||||
final ApprovalHistoryController controller;
|
||||
final int actorId;
|
||||
|
||||
@override
|
||||
State<_ApprovalActionHarness> createState() => _ApprovalActionHarnessState();
|
||||
}
|
||||
|
||||
class _ApprovalActionHarnessState extends State<_ApprovalActionHarness> {
|
||||
final TextEditingController _recallNoteController = TextEditingController();
|
||||
final TextEditingController _resubmitNoteController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: ShadTheme(
|
||||
data: ShadThemeData(
|
||||
colorScheme: const ShadSlateColorScheme.light(),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
child: Scaffold(
|
||||
body: AnimatedBuilder(
|
||||
animation: widget.controller,
|
||||
builder: (context, _) {
|
||||
final flow = widget.controller.selectedFlow;
|
||||
if (flow == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final canRecall = _canRecall(flow);
|
||||
final canResubmit = _canResubmit(flow);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadInput(
|
||||
key: const ValueKey('recall-note-field'),
|
||||
controller: _recallNoteController,
|
||||
placeholder: const Text('회수 사유'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ShadButton(
|
||||
onPressed: canRecall ? () => _handleRecall(flow) : null,
|
||||
child: const Text('회수'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ShadInput(
|
||||
key: const ValueKey('resubmit-note-field'),
|
||||
controller: _resubmitNoteController,
|
||||
placeholder: const Text('재상신 메모'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ShadButton.outline(
|
||||
onPressed: canResubmit
|
||||
? () => _handleResubmit(flow)
|
||||
: null,
|
||||
child: const Text('재상신'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _canRecall(ApprovalFlow flow) {
|
||||
if (flow.status.isTerminal) {
|
||||
return false;
|
||||
}
|
||||
if (flow.steps.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return flow.steps.first.decidedAt == null;
|
||||
}
|
||||
|
||||
bool _canResubmit(ApprovalFlow flow) {
|
||||
if (!flow.status.isTerminal) {
|
||||
return false;
|
||||
}
|
||||
final statusName = flow.status.name.toLowerCase();
|
||||
return statusName.contains('반려') || statusName.contains('reject');
|
||||
}
|
||||
|
||||
Future<void> _handleRecall(ApprovalFlow flow) async {
|
||||
final latestFlow = await widget.controller.refreshFlow(flow.id!) ?? flow;
|
||||
final transactionUpdatedAt = latestFlow.transactionUpdatedAt;
|
||||
if (transactionUpdatedAt == null) {
|
||||
return;
|
||||
}
|
||||
final input = ApprovalRecallInput(
|
||||
approvalId: latestFlow.id!,
|
||||
actorId: widget.actorId,
|
||||
note: _trimmedOrNull(_recallNoteController.text),
|
||||
expectedUpdatedAt: latestFlow.approval.updatedAt,
|
||||
transactionExpectedUpdatedAt: transactionUpdatedAt,
|
||||
);
|
||||
await widget.controller.recallApproval(input);
|
||||
}
|
||||
|
||||
Future<void> _handleResubmit(ApprovalFlow flow) async {
|
||||
final latestFlow = await widget.controller.refreshFlow(flow.id!) ?? flow;
|
||||
final transactionUpdatedAt = latestFlow.transactionUpdatedAt;
|
||||
if (transactionUpdatedAt == null) {
|
||||
return;
|
||||
}
|
||||
final steps = latestFlow.steps
|
||||
.map(
|
||||
(step) => ApprovalStepAssignmentItem(
|
||||
stepOrder: step.stepOrder,
|
||||
approverId: step.approver.id,
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
final submission = ApprovalSubmissionInput(
|
||||
transactionId: latestFlow.transactionId,
|
||||
statusId: latestFlow.status.id,
|
||||
requesterId: latestFlow.requester.id,
|
||||
finalApproverId: latestFlow.finalApprover?.id,
|
||||
note: latestFlow.note,
|
||||
steps: steps,
|
||||
);
|
||||
final input = ApprovalResubmissionInput(
|
||||
approvalId: latestFlow.id!,
|
||||
actorId: widget.actorId,
|
||||
submission: submission,
|
||||
note: _trimmedOrNull(_resubmitNoteController.text),
|
||||
expectedUpdatedAt: latestFlow.approval.updatedAt,
|
||||
transactionExpectedUpdatedAt: transactionUpdatedAt,
|
||||
);
|
||||
await widget.controller.resubmitApproval(input);
|
||||
}
|
||||
|
||||
String? _trimmedOrNull(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_recallNoteController.dispose();
|
||||
_resubmitNoteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user