결재 템플릿 단계 적용 구현
- ApprovalTemplate 엔티티·DTO·원격 리포지토리 추가 - ApprovalController에 템플릿 로딩/적용 상태와 assignSteps 호출 연동 - ApprovalPage 단계 탭에 템플릿 선택 UI 및 적용 확인 다이얼로그 구현 - 템플릿 적용 단위 테스트와 IMPLEMENTATION_TASKS 현황 갱신
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
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/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';
|
||||
|
||||
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
|
||||
|
||||
class _FakeApprovalInput extends Fake implements ApprovalInput {}
|
||||
|
||||
class _FakeStepActionInput extends Fake implements ApprovalStepActionInput {}
|
||||
|
||||
class _MockApprovalTemplateRepository extends Mock
|
||||
implements ApprovalTemplateRepository {}
|
||||
|
||||
class _FakeStepAssignmentInput extends Fake
|
||||
implements ApprovalStepAssignmentInput {}
|
||||
|
||||
void main() {
|
||||
late ApprovalController controller;
|
||||
late _MockApprovalRepository repository;
|
||||
late _MockApprovalTemplateRepository templateRepository;
|
||||
|
||||
final sampleStep = ApprovalStep(
|
||||
id: 11,
|
||||
stepOrder: 1,
|
||||
approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'),
|
||||
status: ApprovalStatus(id: 1, name: '대기'),
|
||||
assignedAt: DateTime(2024, 4, 1, 9),
|
||||
);
|
||||
|
||||
final sampleApproval = Approval(
|
||||
id: 1,
|
||||
approvalNo: 'AP-24001',
|
||||
transactionNo: 'TRX-001',
|
||||
status: ApprovalStatus(id: 1, name: '대기'),
|
||||
currentStep: sampleStep,
|
||||
requester: ApprovalRequester(id: 31, employeeNo: 'EMP001', name: '김상신'),
|
||||
requestedAt: DateTime(2024, 4, 1, 9),
|
||||
note: '긴급 결재',
|
||||
steps: [sampleStep],
|
||||
histories: const [],
|
||||
);
|
||||
|
||||
PaginatedResult<Approval> createResult(List<Approval> items) {
|
||||
return PaginatedResult<Approval>(
|
||||
items: items,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: items.length,
|
||||
);
|
||||
}
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(_FakeApprovalInput());
|
||||
registerFallbackValue(_FakeStepActionInput());
|
||||
registerFallbackValue(_FakeStepAssignmentInput());
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
repository = _MockApprovalRepository();
|
||||
templateRepository = _MockApprovalTemplateRepository();
|
||||
controller = ApprovalController(
|
||||
approvalRepository: repository,
|
||||
templateRepository: templateRepository,
|
||||
);
|
||||
});
|
||||
|
||||
group('fetch', () {
|
||||
setUp(() {
|
||||
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 => createResult([sampleApproval]));
|
||||
});
|
||||
|
||||
test('목록을 조회한다', () async {
|
||||
await controller.fetch();
|
||||
|
||||
expect(controller.result?.items, isNotEmpty);
|
||||
expect(controller.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test('필터 전달을 검증한다', () async {
|
||||
controller.updateQuery('TRX');
|
||||
controller.updateStatusFilter(ApprovalStatusFilter.approved);
|
||||
final from = DateTime(2024, 4, 1);
|
||||
final to = DateTime(2024, 4, 30);
|
||||
controller.updateDateRange(from, to);
|
||||
|
||||
await controller.fetch(page: 3);
|
||||
|
||||
verify(
|
||||
() => repository.list(
|
||||
page: 3,
|
||||
pageSize: 20,
|
||||
query: 'TRX',
|
||||
status: 'approved',
|
||||
from: from,
|
||||
to: to,
|
||||
includeHistories: false,
|
||||
includeSteps: false,
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('에러 발생 시 errorMessage 설정', () async {
|
||||
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'),
|
||||
),
|
||||
).thenThrow(Exception('fail'));
|
||||
|
||||
await controller.fetch();
|
||||
|
||||
expect(controller.errorMessage, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('selectApproval', () {
|
||||
test('상세를 조회하고 저장한다', () async {
|
||||
when(
|
||||
() => repository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenAnswer((_) async => sampleApproval);
|
||||
|
||||
await controller.selectApproval(1);
|
||||
|
||||
expect(controller.selected, isNotNull);
|
||||
verify(
|
||||
() => repository.fetchDetail(
|
||||
1,
|
||||
includeSteps: true,
|
||||
includeHistories: true,
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('에러 발생 시 errorMessage 설정', () async {
|
||||
when(
|
||||
() => repository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenThrow(Exception('detail fail'));
|
||||
|
||||
await controller.selectApproval(1);
|
||||
|
||||
expect(controller.errorMessage, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('loadActionOptions', () {
|
||||
test('행위 목록을 불러오고 캐시한다', () async {
|
||||
when(
|
||||
() => repository.listActions(activeOnly: any(named: 'activeOnly')),
|
||||
).thenAnswer((_) async => [ApprovalAction(id: 1, name: 'approve')]);
|
||||
|
||||
await controller.loadActionOptions(force: true);
|
||||
|
||||
expect(controller.hasActionOptions, isTrue);
|
||||
expect(controller.actionOptions.length, 1);
|
||||
|
||||
clearInteractions(repository);
|
||||
|
||||
await controller.loadActionOptions();
|
||||
|
||||
verifyNever(
|
||||
() => repository.listActions(activeOnly: any(named: 'activeOnly')),
|
||||
);
|
||||
});
|
||||
|
||||
test('에러 발생 시 errorMessage 설정', () async {
|
||||
when(
|
||||
() => repository.listActions(activeOnly: any(named: 'activeOnly')),
|
||||
).thenThrow(Exception('actions fail'));
|
||||
|
||||
await controller.loadActionOptions(force: true);
|
||||
|
||||
expect(controller.errorMessage, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('loadTemplates', () {
|
||||
test('템플릿 목록을 불러오고 캐시한다', () async {
|
||||
when(
|
||||
() => templateRepository.list(activeOnly: any(named: 'activeOnly')),
|
||||
).thenAnswer(
|
||||
(_) async => [
|
||||
ApprovalTemplate(id: 1, code: 'TEMP', name: '기본 템플릿', isActive: true),
|
||||
],
|
||||
);
|
||||
|
||||
await controller.loadTemplates(force: true);
|
||||
|
||||
expect(controller.templates.length, 1);
|
||||
expect(controller.isLoadingTemplates, isFalse);
|
||||
|
||||
clearInteractions(templateRepository);
|
||||
|
||||
await controller.loadTemplates();
|
||||
|
||||
verifyNever(
|
||||
() => templateRepository.list(activeOnly: any(named: 'activeOnly')),
|
||||
);
|
||||
});
|
||||
|
||||
test('에러 발생 시 errorMessage 설정', () async {
|
||||
when(
|
||||
() => templateRepository.list(activeOnly: any(named: 'activeOnly')),
|
||||
).thenThrow(Exception('template fail'));
|
||||
|
||||
await controller.loadTemplates(force: true);
|
||||
|
||||
expect(controller.errorMessage, isNotNull);
|
||||
expect(controller.isLoadingTemplates, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('performStepAction', () {
|
||||
late ApprovalStep updatedStep;
|
||||
late Approval updatedApproval;
|
||||
|
||||
setUp(() {
|
||||
when(
|
||||
() => repository.listActions(activeOnly: any(named: 'activeOnly')),
|
||||
).thenAnswer(
|
||||
(_) async => [
|
||||
ApprovalAction(id: 1, name: 'approve'),
|
||||
ApprovalAction(id: 2, name: 'reject'),
|
||||
ApprovalAction(id: 3, name: 'comment'),
|
||||
],
|
||||
);
|
||||
|
||||
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 => createResult([sampleApproval]));
|
||||
|
||||
when(
|
||||
() => repository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenAnswer((_) async => sampleApproval);
|
||||
|
||||
updatedStep = ApprovalStep(
|
||||
id: 11,
|
||||
stepOrder: 1,
|
||||
approver: sampleStep.approver,
|
||||
status: ApprovalStatus(id: 2, name: '승인'),
|
||||
assignedAt: sampleStep.assignedAt,
|
||||
decidedAt: DateTime(2024, 4, 1, 9, 30),
|
||||
note: '승인 완료',
|
||||
);
|
||||
|
||||
updatedApproval = sampleApproval.copyWith(
|
||||
status: ApprovalStatus(id: 2, name: '승인'),
|
||||
currentStep: updatedStep,
|
||||
steps: [updatedStep],
|
||||
);
|
||||
});
|
||||
|
||||
test('성공 시 상세와 목록을 갱신한다', () async {
|
||||
when(
|
||||
() => repository.performStepAction(any()),
|
||||
).thenAnswer((_) async => updatedApproval);
|
||||
|
||||
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, isTrue);
|
||||
expect(controller.selected?.status.name, '승인');
|
||||
expect(controller.result?.items.first.status.name, '승인');
|
||||
expect(controller.isPerformingAction, isFalse);
|
||||
verify(() => repository.performStepAction(any())).called(1);
|
||||
});
|
||||
|
||||
test('예외 발생 시 errorMessage 설정', () async {
|
||||
when(
|
||||
() => repository.performStepAction(any()),
|
||||
).thenThrow(Exception('action fail'));
|
||||
|
||||
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, isNotNull);
|
||||
expect(controller.isPerformingAction, isFalse);
|
||||
});
|
||||
|
||||
test('행위를 찾지 못하면 요청하지 않는다', () async {
|
||||
when(
|
||||
() => repository.listActions(activeOnly: any(named: 'activeOnly')),
|
||||
).thenAnswer((_) async => [ApprovalAction(id: 9, name: 'other')]);
|
||||
|
||||
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, isNotNull);
|
||||
verifyNever(() => repository.performStepAction(any()));
|
||||
});
|
||||
});
|
||||
|
||||
group('applyTemplate', () {
|
||||
late ApprovalTemplate template;
|
||||
late Approval updatedApproval;
|
||||
|
||||
setUp(() {
|
||||
template = ApprovalTemplate(
|
||||
id: 901,
|
||||
code: 'TEMP-001',
|
||||
name: '입고 1단계',
|
||||
isActive: true,
|
||||
steps: [
|
||||
ApprovalTemplateStep(
|
||||
stepOrder: 1,
|
||||
approver: ApprovalTemplateApprover(
|
||||
id: 21,
|
||||
employeeNo: 'E001',
|
||||
name: '최승인',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
when(
|
||||
() => templateRepository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
),
|
||||
).thenAnswer((_) async => template);
|
||||
|
||||
when(
|
||||
() => templateRepository.list(activeOnly: any(named: 'activeOnly')),
|
||||
).thenAnswer((_) async => [template]);
|
||||
|
||||
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 => createResult([sampleApproval]));
|
||||
|
||||
when(
|
||||
() => repository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenAnswer((_) async => sampleApproval);
|
||||
|
||||
updatedApproval = sampleApproval.copyWith(
|
||||
steps: [
|
||||
ApprovalStep(
|
||||
id: 11,
|
||||
stepOrder: 1,
|
||||
approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'),
|
||||
status: ApprovalStatus(id: 1, name: '대기'),
|
||||
assignedAt: DateTime(2024, 4, 1, 9),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('성공 시 결재 상세를 갱신한다', () async {
|
||||
when(
|
||||
() => repository.assignSteps(any()),
|
||||
).thenAnswer((_) async => updatedApproval);
|
||||
|
||||
await controller.loadTemplates(force: true);
|
||||
await controller.fetch();
|
||||
await controller.selectApproval(sampleApproval.id!);
|
||||
|
||||
final success = await controller.applyTemplate(template.id);
|
||||
|
||||
expect(success, isTrue);
|
||||
expect(controller.selected?.steps.length, 1);
|
||||
verify(() => repository.assignSteps(any())).called(1);
|
||||
});
|
||||
|
||||
test('템플릿 단계가 없으면 실패한다', () async {
|
||||
when(
|
||||
() => templateRepository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => ApprovalTemplate(
|
||||
id: template.id,
|
||||
code: template.code,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
isActive: template.isActive,
|
||||
createdBy: template.createdBy,
|
||||
createdAt: template.createdAt,
|
||||
updatedAt: template.updatedAt,
|
||||
steps: const [],
|
||||
),
|
||||
);
|
||||
|
||||
await controller.selectApproval(sampleApproval.id!);
|
||||
|
||||
final success = await controller.applyTemplate(template.id);
|
||||
|
||||
expect(success, isFalse);
|
||||
expect(controller.errorMessage, isNotNull);
|
||||
});
|
||||
|
||||
test('예외 발생 시 errorMessage 설정', () async {
|
||||
when(
|
||||
() => templateRepository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
),
|
||||
).thenThrow(Exception('template detail fail'));
|
||||
|
||||
await controller.selectApproval(sampleApproval.id!);
|
||||
|
||||
final success = await controller.applyTemplate(template.id);
|
||||
|
||||
expect(success, isFalse);
|
||||
expect(controller.errorMessage, isNotNull);
|
||||
});
|
||||
|
||||
test('선택된 결재가 없으면 적용하지 않는다', () async {
|
||||
final success = await controller.applyTemplate(template.id);
|
||||
|
||||
expect(success, isFalse);
|
||||
expect(controller.errorMessage, isNotNull);
|
||||
verifyNever(() => repository.assignSteps(any()));
|
||||
});
|
||||
});
|
||||
|
||||
test('필터 초기화', () {
|
||||
controller.updateQuery('abc');
|
||||
controller.updateStatusFilter(ApprovalStatusFilter.rejected);
|
||||
controller.updateDateRange(DateTime(2024, 1, 1), DateTime(2024, 1, 31));
|
||||
|
||||
controller.clearFilters();
|
||||
|
||||
expect(controller.query, isEmpty);
|
||||
expect(controller.statusFilter, ApprovalStatusFilter.all);
|
||||
expect(controller.fromDate, isNull);
|
||||
expect(controller.toDate, isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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: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/repositories/approval_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/presentation/pages/approval_page.dart';
|
||||
|
||||
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
|
||||
|
||||
class _FakeApprovalInput extends Fake implements ApprovalInput {}
|
||||
|
||||
Widget _buildApp(Widget child) {
|
||||
return MaterialApp(
|
||||
home: ShadTheme(
|
||||
data: ShadThemeData(
|
||||
colorScheme: const ShadSlateColorScheme.light(),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
child: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(_FakeApprovalInput());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await GetIt.I.reset();
|
||||
dotenv.clean();
|
||||
});
|
||||
|
||||
testWidgets('플래그 Off 시 스펙 화면', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=false\n');
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ApprovalPage()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('결재 관리'), findsOneWidget);
|
||||
expect(find.text('비활성화 (백엔드 준비 중)'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('플래그 On', () {
|
||||
late _MockApprovalRepository repository;
|
||||
|
||||
setUp(() {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
|
||||
repository = _MockApprovalRepository();
|
||||
GetIt.I.registerLazySingleton<ApprovalRepository>(() => repository);
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user