결재 템플릿 단계 적용 구현

- ApprovalTemplate 엔티티·DTO·원격 리포지토리 추가
- ApprovalController에 템플릿 로딩/적용 상태와 assignSteps 호출 연동
- ApprovalPage 단계 탭에 템플릿 선택 UI 및 적용 확인 다이얼로그 구현
- 템플릿 적용 단위 테스트와 IMPLEMENTATION_TASKS 현황 갱신
This commit is contained in:
JiWoong Sul
2025-09-25 00:21:12 +09:00
parent b6e50464d2
commit c3010965ad
63 changed files with 10179 additions and 1436 deletions

View File

@@ -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);
});
}

View File

@@ -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);
});
});
}