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('접근 불가'));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user