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:
JiWoong Sul
2025-10-31 01:05:39 +09:00
parent 259b056072
commit d76f765814
133 changed files with 13878 additions and 947 deletions

View File

@@ -150,5 +150,56 @@ void main() {
expect(client.unwrap(response), {'status': 'ok'});
});
});
group('buildPath', () {
test('선행 슬래시를 유지하며 세그먼트를 결합한다', () {
final path = ApiClient.buildPath('/api/v1', ['approvals', 1, 'steps']);
expect(path, '/api/v1/approvals/1/steps');
});
test('세그먼트의 중복 슬래시를 제거한다', () {
final path = ApiClient.buildPath('/api/v1/', ['/approval/', '/submit']);
expect(path, '/api/v1/approval/submit');
});
});
group('buildQuery', () {
test('페이지네이션/검색 파라미터를 정규화한다', () {
final now = DateTime.utc(2025, 1, 5, 12, 30);
final query = ApiClient.buildQuery(
page: 2,
pageSize: 50,
q: ' 품번 ',
sort: ' transaction_date ',
order: ' DESC ',
include: const ['steps', 'histories', 'steps'],
updatedSince: now,
filters: {
'transaction_id': 10,
'status': ' 진행 ',
'keyword': ' ',
'from': DateTime.utc(2025, 1, 1, 9),
'ids': [' 1 ', null, '2'],
},
);
expect(query['page'], 2);
expect(query['page_size'], 50);
expect(query['q'], '품번');
expect(query['sort'], 'transaction_date');
expect(query['order'], 'desc');
expect(query['include'], 'steps,histories');
expect(query['updated_since'], now.toUtc().toIso8601String());
expect(query['transaction_id'], 10);
expect(query['status'], '진행');
expect(query.containsKey('keyword'), isFalse);
expect(
query['from'],
DateTime.utc(2025, 1, 1, 9).toUtc().toIso8601String(),
);
expect(query['ids'], '1,2');
expect(() => query['page'] = 1, throwsUnsupportedError);
});
});
});
}

View File

@@ -92,9 +92,7 @@ void main() {
requestOptions: requestOptions,
statusCode: 401,
data: {
'error': {
'message': 'invalid credentials',
},
'error': {'message': 'invalid credentials'},
},
),
);

View File

@@ -18,14 +18,38 @@ import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_i
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../../helpers/test_app.dart';
import '../../helpers/fixture_loader.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Map<String, dynamic> permissionFixture;
late Set<PermissionAction> viewerPermissions;
late Set<PermissionAction> approverPermissions;
Set<PermissionAction> parseActions(String key) {
final raw = permissionFixture[key];
if (raw is! List) {
return {};
}
return raw
.whereType<String>()
.map(
(action) => PermissionAction.values.firstWhere(
(candidate) => candidate.name == action,
orElse: () => PermissionAction.view,
),
)
.toSet();
}
setUpAll(() async {
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true');
await Environment.initialize();
dotenv.env['FEATURE_APPROVALS_ENABLED'] = 'true';
permissionFixture = loadJsonFixture('approvals/approval_permissions.json');
viewerPermissions = parseActions('viewer');
approverPermissions = parseActions('approver');
});
tearDown(() async {
@@ -52,9 +76,7 @@ void main() {
GetIt.I.registerSingleton<InventoryLookupRepository>(lookupRepo);
final permissionManager = PermissionManager(
overrides: {
PermissionResources.approvals: {PermissionAction.view},
},
overrides: {PermissionResources.approvals: viewerPermissions},
);
final view = tester.view;
@@ -95,12 +117,7 @@ void main() {
GetIt.I.registerSingleton<InventoryLookupRepository>(lookupRepo);
final permissionManager = PermissionManager(
overrides: {
PermissionResources.approvals: {
PermissionAction.view,
PermissionAction.approve,
},
},
overrides: {PermissionResources.approvals: approverPermissions},
);
await pumpApprovalPage(tester, permissionManager);
@@ -133,12 +150,7 @@ void main() {
GetIt.I.registerSingleton<InventoryLookupRepository>(lookupRepo);
final permissionManager = PermissionManager(
overrides: {
PermissionResources.approvals: {
PermissionAction.view,
PermissionAction.approve,
},
},
overrides: {PermissionResources.approvals: approverPermissions},
);
await pumpApprovalPage(tester, permissionManager);
@@ -200,6 +212,8 @@ class _StubApprovalRepository implements ApprovalRepository {
int? transactionId,
int? approvalStatusId,
int? requestedById,
List<String>? statusCodes,
bool includePending = false,
bool includeHistories = false,
bool includeSteps = false,
}) async {
@@ -220,6 +234,31 @@ class _StubApprovalRepository implements ApprovalRepository {
return _approval;
}
@override
Future<Approval> submit(ApprovalSubmissionInput input) async {
return _approval;
}
@override
Future<Approval> resubmit(ApprovalResubmissionInput input) async {
return _approval;
}
@override
Future<Approval> approve(ApprovalDecisionInput input) async {
return _approval;
}
@override
Future<Approval> reject(ApprovalDecisionInput input) async {
return _approval;
}
@override
Future<Approval> recall(ApprovalRecallInput input) async {
return _approval;
}
@override
Future<List<ApprovalAction>> listActions({bool activeOnly = true}) async {
return [
@@ -229,6 +268,24 @@ class _StubApprovalRepository implements ApprovalRepository {
];
}
@override
Future<PaginatedResult<ApprovalHistory>> listHistory({
required int approvalId,
int page = 1,
int pageSize = 20,
DateTime? from,
DateTime? to,
int? actorId,
int? approvalActionId,
}) async {
return PaginatedResult(
items: const <ApprovalHistory>[],
page: page,
pageSize: pageSize,
total: 0,
);
}
@override
Future<Approval> performStepAction(ApprovalStepActionInput input) async {
return _approval;

View File

@@ -0,0 +1,193 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/approvals/data/repositories/approval_draft_repository_remote.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart';
class _MockApiClient extends Mock implements ApiClient {}
void main() {
late ApiClient apiClient;
late ApprovalDraftRepositoryRemote repository;
setUpAll(() {
registerFallbackValue(Options());
registerFallbackValue(CancelToken());
registerFallbackValue(
Response<dynamic>(requestOptions: RequestOptions(path: '/')),
);
});
setUp(() {
apiClient = _MockApiClient();
repository = ApprovalDraftRepositoryRemote(apiClient: apiClient);
});
test('list는 requester_id와 include_expired 플래그를 전달한다', () async {
const path = '/api/v1/approval-drafts';
when(
() => apiClient.get<Map<String, dynamic>>(
path,
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {'items': const [], 'page': 1, 'page_size': 20, 'total': 0},
statusCode: 200,
requestOptions: RequestOptions(path: path),
),
);
await repository.list(
const ApprovalDraftListFilter(requesterId: 7, includeExpired: true),
);
final captured = verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured;
expect(captured.first, equals(path));
final query = captured[1] as Map<String, dynamic>;
expect(query['requester_id'], 7);
expect(query['include_expired'], isTrue);
});
test('fetch는 requester_id 쿼리로 상세를 조회한다', () async {
const id = 11;
const path = '/api/v1/approval-drafts/$id';
when(
() => apiClient.get<Map<String, dynamic>>(
path,
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {
'data': {
'id': id,
'requester_id': 5,
'saved_at': '2025-01-01T00:00:00Z',
'payload': {'steps': const []},
},
},
statusCode: 200,
requestOptions: RequestOptions(path: path),
),
);
final detail = await repository.fetch(id: id, requesterId: 5);
expect(detail, isNotNull);
expect(detail!.id, id);
final captured = verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured;
expect(captured.first, equals(path));
final query = captured[1] as Map<String, dynamic>;
expect(query['requester_id'], 5);
});
test('save는 초안 상세를 반환한다', () async {
const path = '/api/v1/approval-drafts';
when(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {
'data': {
'id': 22,
'requester_id': 9,
'saved_at': '2025-01-02T00:00:00Z',
'payload': {
'steps': const [
{'step_order': 1, 'approver_id': 30},
],
},
},
},
statusCode: 200,
requestOptions: RequestOptions(path: path),
),
);
final input = ApprovalDraftSaveInput(
requesterId: 9,
steps: [ApprovalDraftStep(stepOrder: 1, approverId: 30)],
);
final detail = await repository.save(input);
expect(detail.id, 22);
expect(detail.requesterId, 9);
final captured = verify(
() => apiClient.post<Map<String, dynamic>>(
captureAny(),
data: captureAny(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured;
expect(captured.first, equals(path));
final payload = captured[1] as Map<String, dynamic>;
expect(payload['requester_id'], 9);
expect(payload['steps'], hasLength(1));
});
test('delete는 requester_id를 포함해 호출한다', () async {
const id = 44;
const path = '/api/v1/approval-drafts/$id';
when(
() => apiClient.delete<void>(
path,
data: any(named: 'data'),
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<void>(
data: null,
statusCode: 204,
requestOptions: RequestOptions(path: path),
),
);
await repository.delete(id: id, requesterId: 5);
final captured = verify(
() => apiClient.delete<void>(
captureAny(),
data: any(named: 'data'),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured;
expect(captured.first, equals(path));
final query = captured[1] as Map<String, dynamic>;
expect(query['requester_id'], 5);
});
}

View File

@@ -82,7 +82,45 @@ void main() {
expect(query['transaction_id'], 10);
expect(query['approval_status_id'], 5);
expect(query['requested_by_id'], 7);
expect(query['include'], 'steps,histories');
expect(query['include'], 'requested_by,transaction,steps,histories');
});
test('list는 status 코드와 include_pending 옵션을 전달한다', () async {
const path = '/api/v1/approvals';
when(
() => apiClient.get<Map<String, dynamic>>(
path,
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {'items': const [], 'page': 1, 'page_size': 20, 'total': 0},
statusCode: 200,
requestOptions: RequestOptions(path: path),
),
);
await repository.list(
statusCodes: const ['draft', 'submitted'],
includePending: true,
);
final captured = verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured;
expect(captured.first, equals(path));
final query = captured[1] as Map<String, dynamic>;
expect(query['status'], 'draft,submitted');
expect(query['include_pending'], true);
expect(query['include'], 'requested_by,transaction');
});
test('create는 필수 필드를 전달한다', () async {
@@ -353,6 +391,56 @@ void main() {
).captured.first
as Map<String, dynamic>;
expect(query['include'], 'steps,histories');
expect(query['include'], 'transaction,requested_by,steps,histories');
});
test('listHistory는 날짜 필터를 ISO 문자열로 직렬화한다', () async {
const path = '/api/v1/approval/history';
when(
() => apiClient.get<Map<String, dynamic>>(
path,
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {'items': const [], 'page': 1, 'page_size': 20, 'total': 0},
statusCode: 200,
requestOptions: RequestOptions(path: path),
),
);
final from = DateTime.utc(2025, 1, 1, 9);
final to = DateTime.utc(2025, 1, 2, 12);
await repository.listHistory(
approvalId: 99,
page: 3,
pageSize: 40,
from: from,
to: to,
actorId: 7,
approvalActionId: 1,
);
final captured = verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured;
final query = captured[1] as Map<String, dynamic>;
expect(captured.first, equals(path));
expect(query['approval_id'], 99);
expect(query['page'], 3);
expect(query['page_size'], 40);
expect(query['action_from'], from.toUtc().toIso8601String());
expect(query['action_to'], to.toUtc().toIso8601String());
expect(query['approver_id'], 7);
expect(query['approval_action_id'], 1);
});
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_draft_dto.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart';
void main() {
group('ApprovalDraftDto', () {
test('parsePaginated가 요약 리스트를 변환한다', () {
final result = ApprovalDraftDto.parsePaginated({
'items': [
{
'id': 10,
'requester_id': 77,
'status': 'active',
'saved_at': '2025-01-01T00:00:00Z',
'step_count': 2,
'session_key': 'session-1',
},
],
'page': 2,
'page_size': 50,
'total': 90,
});
expect(result.page, 2);
expect(result.pageSize, 50);
expect(result.total, 90);
expect(result.items, hasLength(1));
final summary = result.items.first;
expect(summary.id, 10);
expect(summary.requesterId, 77);
expect(summary.status, ApprovalDraftStatus.active);
expect(summary.stepCount, 2);
expect(summary.sessionKey, 'session-1');
});
test('parseDetail이 상세 정보를 반환한다', () {
final detail = ApprovalDraftDto.parseDetail({
'data': {
'id': 5,
'requester_id': 11,
'saved_at': '2025-01-02T12:00:00Z',
'payload': {
'title': '대여 결재',
'summary': '사전 확인',
'metadata': {
'_client_state': {'status_id': 4},
},
'steps': [
{'step_order': 1, 'approver_id': 20, 'note': '검토'},
],
},
},
});
expect(detail, isNotNull);
final value = detail!;
expect(value.id, 5);
expect(value.requesterId, 11);
expect(value.payload.title, '대여 결재');
expect(value.payload.steps, hasLength(1));
expect(value.payload.steps.first.approverId, 20);
});
});
}

View File

@@ -0,0 +1,138 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_request_dto.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
void main() {
group('ApprovalSubmitRequestDto', () {
test('toJson은 상신 본문과 단계를 직렬화한다', () {
final submission = ApprovalSubmissionInput(
transactionId: 1001,
templateId: 55,
statusId: 7,
requesterId: 22,
finalApproverId: 99,
requestedAt: DateTime.utc(2025, 1, 1, 9, 30),
lastActionAt: DateTime.utc(2025, 1, 3, 8, 15),
title: ' 결재 제목 ',
summary: ' 결재 요약 ',
note: ' 비고 ',
metadata: const {'channel': 'web'},
steps: [
ApprovalStepAssignmentItem(
stepOrder: 1,
approverId: 300,
note: ' 1차 ',
),
],
);
final dto = ApprovalSubmitRequestDto(
approval: ApprovalCreatePayloadDto.fromSubmission(submission),
steps: submission.steps
.map(ApprovalStepInputDto.fromDomain)
.toList(growable: false),
);
final json = dto.toJson();
expect(json['approval'], isA<Map<String, dynamic>>());
expect(json['steps'], isA<List<dynamic>>());
final approvalJson = json['approval'] as Map<String, dynamic>;
expect(approvalJson['transaction_id'], 1001);
expect(approvalJson['template_id'], 55);
expect(approvalJson['approval_status_id'], 7);
expect(approvalJson['requested_by_id'], 22);
expect(approvalJson['final_approver_id'], 99);
expect(
approvalJson['requested_at'],
DateTime.utc(2025, 1, 1, 9, 30).toIso8601String(),
);
expect(approvalJson.containsKey('decided_at'), isFalse);
expect(
approvalJson['last_action_at'],
DateTime.utc(2025, 1, 3, 8, 15).toIso8601String(),
);
expect(approvalJson['title'], '결재 제목');
expect(approvalJson['summary'], '결재 요약');
expect(approvalJson['note'], '비고');
expect(approvalJson['metadata'], {'channel': 'web'});
final steps = json['steps'] as List<dynamic>;
expect(steps, hasLength(1));
final stepJson = steps.first as Map<String, dynamic>;
expect(stepJson['step_order'], 1);
expect(stepJson['approver_id'], 300);
expect(stepJson['note'], '1차');
});
});
group('ApprovalResubmitRequestDto', () {
test('toJson은 옵션 필드와 타임스탬프를 포함한다', () {
final dto = ApprovalResubmitRequestDto(
approvalId: 700,
actorId: 123,
steps: [
ApprovalStepInputDto(stepOrder: 1, approverId: 45, note: ' 의견 '),
],
note: ' 재상신 ',
expectedUpdatedAt: DateTime.utc(2025, 2, 1, 10, 0),
transactionExpectedUpdatedAt: DateTime.utc(2025, 2, 1, 11, 0),
);
final json = dto.toJson();
expect(json['approval_id'], 700);
expect(json['actor_id'], 123);
expect(json['note'], '재상신');
expect(
json['expected_updated_at'],
DateTime.utc(2025, 2, 1, 10, 0).toIso8601String(),
);
expect(
json['transaction_expected_updated_at'],
DateTime.utc(2025, 2, 1, 11, 0).toIso8601String(),
);
final steps = json['steps'] as List<dynamic>;
expect(steps, hasLength(1));
expect((steps.first as Map<String, dynamic>)['note'], '의견');
});
});
group('ApprovalRecallRequestDto', () {
test('회수 요청은 메모가 없으면 note를 누락한다', () {
final dto = ApprovalRecallRequestDto(
approvalId: 501,
actorId: 88,
expectedUpdatedAt: DateTime.utc(2025, 3, 1, 12, 0),
transactionExpectedUpdatedAt: DateTime.utc(2025, 3, 1, 13, 0),
);
final json = dto.toJson();
expect(json['approval_id'], 501);
expect(json['actor_id'], 88);
expect(json.containsKey('note'), isFalse);
expect(
json['expected_updated_at'],
DateTime.utc(2025, 3, 1, 12, 0).toIso8601String(),
);
expect(
json['transaction_expected_updated_at'],
DateTime.utc(2025, 3, 1, 13, 0).toIso8601String(),
);
});
});
group('ApprovalDecisionRequestDto', () {
test('toJson은 비어 있는 메모를 포함하지 않는다', () {
final dto = ApprovalDecisionRequestDto(
approvalId: 301,
actorId: 44,
note: ' ',
);
final json = dto.toJson();
expect(json['approval_id'], 301);
expect(json['actor_id'], 44);
expect(json.containsKey('note'), isFalse);
});
});
}

View File

@@ -0,0 +1,148 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_audit_dto.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_request_dto.dart';
void main() {
group('ApprovalAuditListDto', () {
test('fromJson은 감사 로그 목록과 페이지 정보를 생성한다', () {
final dto = ApprovalAuditListDto.fromJson({
'items': [
{
'id': 1,
'action': {'id': 10, 'name': '상신'},
'to_status': {'id': 3, 'name': '진행중'},
'actor': {'id': 20, 'employee_no': 'EMP20', 'name': '홍길동'},
'action_at': '2025-01-01T09:00:00Z',
'note': '테스트',
},
],
'page': 2,
'page_size': 50,
'total': 80,
});
expect(dto.page, 2);
expect(dto.pageSize, 50);
expect(dto.total, 80);
expect(dto.items, hasLength(1));
final item = dto.items.first;
expect(item.id, 1);
expect(item.action.id, 10);
expect(item.toStatus.name, '진행중');
expect(item.actor.employeeNo, 'EMP20');
expect(item.actionAt.toUtc(), DateTime.utc(2025, 1, 1, 9));
expect(item.note, '테스트');
});
test('ApprovalAuditDto.fromJson은 액션 이름 누락 시 예외를 발생시킨다', () {
expect(
() => ApprovalAuditDto.fromJson({
'id': 9,
'action': {'id': 33},
'to_status': {'id': 2, 'name': '진행'},
'actor': {'id': 5, 'employee_no': 'EMP5', 'name': '최사용'},
'action_at': '2025-01-01T09:00:00Z',
}),
throwsFormatException,
);
});
});
group('ApprovalDto', () {
test('fromJson은 중첩 구조에서도 필드를 추출한다', () {
final dto = ApprovalDto.fromJson(_approvalJson());
final entity = dto.toEntity();
expect(entity.id, 5001);
expect(entity.approvalNo, 'APP-2025-0001');
expect(entity.transactionId, 77);
expect(entity.transactionNo, 'TRX-77');
expect(entity.transactionUpdatedAt, DateTime.utc(2025, 1, 1, 9, 30));
expect(entity.status.name, '진행중');
expect(entity.requester.employeeNo, 'EMP-700');
expect(entity.currentStep?.approver.name, '김승인');
expect(entity.steps, hasLength(1));
expect(entity.histories, hasLength(1));
expect(entity.histories.first.action.name, '상신');
expect(entity.createdAt, DateTime.utc(2025, 1, 1, 8));
expect(entity.updatedAt, DateTime.utc(2025, 1, 1, 9));
});
test('parsePaginated는 페이징 결과를 반환한다', () {
final result = ApprovalDto.parsePaginated({
'items': [_approvalJson()],
'page': 3,
'page_size': 25,
'total': 40,
});
expect(result.page, 3);
expect(result.pageSize, 25);
expect(result.total, 40);
expect(result.items, hasLength(1));
expect(result.items.first.approvalNo, 'APP-2025-0001');
});
test('fromJson은 요청자 요약 누락 시 기본 ID를 보존한다', () {
final dto = ApprovalDto.fromJson({
'id': 6001,
'approval_no': 'APP-2025-06001',
'status': {'id': 1, 'name': '대기'},
'requested_at': '2025-01-01T00:00:00Z',
'requester_id': 77,
'requested_by_id': 77,
'requester': const <String, dynamic>{},
});
expect(dto.requester.id, 77);
expect(dto.requester.employeeNo, '-');
expect(dto.requester.name, '-');
});
});
}
Map<String, dynamic> _approvalJson() {
return {
'id': 5001,
'approval': {
'approval_no': 'APP-2025-0001',
'requested_at': '2025-01-01T08:00:00Z',
'status': {'id': 30, 'name': '진행중'},
'requester': {'id': 700, 'employee_no': 'EMP-700', 'name': '상신자'},
'transaction': {
'id': 77,
'transaction_no': 'TRX-77',
'updated_at': '2025-01-01T09:30:00Z',
},
'steps': [
{
'id': 800,
'step_order': 1,
'approver': {'id': 910, 'employee_no': 'EMP-910', 'name': '김승인'},
'status': {'id': 30, 'name': '진행중'},
'assigned_at': '2025-01-01T08:00:00Z',
},
],
'histories': [
{
'id': 1,
'action': {'id': 10, 'name': '상신'},
'to_status': {'id': 30, 'name': '진행중'},
'actor': {'id': 700, 'employee_no': 'EMP-700', 'name': '상신자'},
'action_at': '2025-01-01T08:05:00Z',
},
],
'created_at': '2025-01-01T08:00:00Z',
'updated_at': '2025-01-01T09:00:00Z',
},
'current_step': {
'id': 800,
'step_order': 1,
'approver': {'id': 910, 'employee_no': 'EMP-910', 'name': '김승인'},
'status': {'id': 30, 'name': '진행중'},
'assigned_at': '2025-01-01T08:00:00Z',
},
};
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart';
void main() {
group('ApprovalDraftSaveInput', () {
test('metadata에 상태 ID를 병합한다', () {
final input = ApprovalDraftSaveInput(
requesterId: 99,
statusId: 7,
metadata: const {'foo': 'bar'},
steps: [ApprovalDraftStep(stepOrder: 1, approverId: 2)],
);
final json = input.toJson();
expect(json['metadata'], isA<Map<String, dynamic>>());
final metadata = json['metadata'] as Map<String, dynamic>;
expect(metadata['_client_state'], isA<Map<String, dynamic>>());
final client = metadata['_client_state'] as Map<String, dynamic>;
expect(client['status_id'], 7);
expect(metadata['foo'], 'bar');
});
});
group('ApprovalDraftDetail', () {
test('toSubmissionInput이 metadata에서 상태 ID를 복원한다', () {
final detail = ApprovalDraftDetail(
id: 1,
requesterId: 10,
savedAt: DateTime.utc(2025, 1, 1),
payload: ApprovalDraftPayload(
templateId: 5,
title: '임시 저장',
metadata: const {
'_client_state': {'status_id': 3},
'note': 'memo',
},
steps: [ApprovalDraftStep(stepOrder: 1, approverId: 42)],
),
);
final submission = detail.toSubmissionInput(defaultStatusId: 1);
expect(submission.requesterId, 10);
expect(submission.statusId, 3);
expect(submission.templateId, 5);
expect(submission.metadata, isA<Map<String, dynamic>>());
final metadata = submission.metadata!;
expect(metadata.containsKey('_client_state'), isFalse);
expect(metadata['note'], 'memo');
expect(submission.steps, hasLength(1));
expect(submission.steps.first.approverId, 42);
});
});
}

View File

@@ -0,0 +1,160 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.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/repositories/approval_repository.dart';
import 'package:superport_v2/features/approvals/domain/usecases/approve_approval_use_case.dart';
import 'package:superport_v2/features/approvals/domain/usecases/reject_approval_use_case.dart';
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
void main() {
late ApprovalRepository repository;
late ApproveApprovalUseCase approveUseCase;
late RejectApprovalUseCase rejectUseCase;
setUpAll(() {
registerFallbackValue(ApprovalDecisionInput(approvalId: 0, actorId: 0));
});
setUp(() {
repository = _MockApprovalRepository();
approveUseCase = ApproveApprovalUseCase(repository: repository);
rejectUseCase = RejectApprovalUseCase(repository: repository);
});
group('ApproveApprovalUseCase', () {
test('권한이 있으면 승인 요청을 위임한다', () async {
const approvalId = 10;
const actorId = 7;
final input = ApprovalDecisionInput(
approvalId: approvalId,
actorId: actorId,
);
final approval = _buildApproval(id: approvalId);
when(() => repository.canProceed(approvalId)).thenAnswer(
(_) async =>
ApprovalProceedStatus(approvalId: approvalId, canProceed: true),
);
when(() => repository.approve(any())).thenAnswer((_) async => approval);
final flow = await approveUseCase(input);
expect(flow.id, approvalId);
verify(() => repository.canProceed(approvalId)).called(1);
final captured =
verify(() => repository.approve(captureAny())).captured.single
as ApprovalDecisionInput;
expect(captured.approvalId, approvalId);
expect(captured.actorId, actorId);
});
test('권한이 없으면 StateError를 던지고 승인 요청을 하지 않는다', () async {
const approvalId = 99;
final input = ApprovalDecisionInput(approvalId: approvalId, actorId: 3);
when(() => repository.canProceed(approvalId)).thenAnswer(
(_) async => ApprovalProceedStatus(
approvalId: approvalId,
canProceed: false,
reason: '승인 권한이 없습니다.',
),
);
expect(
() => approveUseCase(input),
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
contains('승인 권한이 없습니다.'),
),
),
);
verify(() => repository.canProceed(approvalId)).called(1);
verifyNever(() => repository.approve(any()));
});
});
group('RejectApprovalUseCase', () {
test('권한이 있으면 반려 요청을 위임한다', () async {
const approvalId = 70;
const actorId = 11;
final input = ApprovalDecisionInput(
approvalId: approvalId,
actorId: actorId,
);
final approval = _buildApproval(id: approvalId);
when(() => repository.canProceed(approvalId)).thenAnswer(
(_) async =>
ApprovalProceedStatus(approvalId: approvalId, canProceed: true),
);
when(() => repository.reject(any())).thenAnswer((_) async => approval);
final flow = await rejectUseCase(input);
expect(flow.id, approvalId);
verify(() => repository.canProceed(approvalId)).called(1);
final captured =
verify(() => repository.reject(captureAny())).captured.single
as ApprovalDecisionInput;
expect(captured.approvalId, approvalId);
expect(captured.actorId, actorId);
});
test('권한이 없으면 StateError를 던지고 반려 요청을 하지 않는다', () async {
const approvalId = 45;
final input = ApprovalDecisionInput(approvalId: approvalId, actorId: 9);
when(() => repository.canProceed(approvalId)).thenAnswer(
(_) async =>
ApprovalProceedStatus(approvalId: approvalId, canProceed: false),
);
expect(
() => rejectUseCase(input),
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
contains('결재를 진행할 권한이 없습니다.'),
),
),
);
verify(() => repository.canProceed(approvalId)).called(1);
verifyNever(() => repository.reject(any()));
});
});
}
Approval _buildApproval({required int id}) {
final status = ApprovalStatus(id: 1, name: '진행중');
final approver = ApprovalApprover(id: 5, employeeNo: 'EMP-5', name: '김승인');
return Approval(
id: id,
approvalNo: 'APP-$id',
transactionNo: 'TRX-$id',
status: status,
currentStep: ApprovalStep(
id: 10,
stepOrder: 1,
approver: approver,
status: status,
assignedAt: DateTime.utc(2025, 1, 1, 9),
),
requester: ApprovalRequester(id: 2, employeeNo: 'EMP-2', name: '상신자'),
requestedAt: DateTime.utc(2025, 1, 1, 9),
steps: [
ApprovalStep(
id: 10,
stepOrder: 1,
approver: approver,
status: status,
assignedAt: DateTime.utc(2025, 1, 1, 9),
),
],
histories: const [],
);
}

View File

@@ -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('접근 불가'));
});
}

View File

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

View File

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

View File

@@ -1,16 +1,24 @@
import 'package:flutter/foundation.dart';
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/data/dtos/approval_dto.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_template.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/repositories/approval_template_repository.dart';
import 'package:superport_v2/features/approvals/domain/usecases/get_approval_draft_use_case.dart';
import 'package:superport_v2/features/approvals/domain/usecases/list_approval_drafts_use_case.dart';
import 'package:superport_v2/features/approvals/domain/usecases/save_approval_draft_use_case.dart';
import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart';
import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart';
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../../../../helpers/fixture_loader.dart';
/// ApprovalRepository 모킹 클래스.
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
@@ -34,31 +42,25 @@ class _FakeStepAssignmentInput extends Fake
class _MockInventoryLookupRepository extends Mock
implements InventoryLookupRepository {}
class _MockSaveApprovalDraftUseCase extends Mock
implements SaveApprovalDraftUseCase {}
class _MockGetApprovalDraftUseCase extends Mock
implements GetApprovalDraftUseCase {}
class _MockListApprovalDraftsUseCase extends Mock
implements ListApprovalDraftsUseCase {}
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 = ApprovalDto.fromJson(
loadJsonFixture('approvals/approval_five_step_pending.json'),
).toEntity();
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 [],
);
final sampleStep = sampleApproval.currentStep ?? sampleApproval.steps.first;
/// 테스트용 페이징 응답을 생성하는 헬퍼.
PaginatedResult<Approval> createResult(List<Approval> items) {
@@ -75,6 +77,13 @@ void main() {
registerFallbackValue(_FakeApprovalUpdateInput());
registerFallbackValue(_FakeStepActionInput());
registerFallbackValue(_FakeStepAssignmentInput());
registerFallbackValue(const ApprovalDraftListFilter(requesterId: 0));
registerFallbackValue(
ApprovalDraftSaveInput(
requesterId: 0,
steps: [ApprovalDraftStep(stepOrder: 0, approverId: 0)],
),
);
});
setUp(() {
@@ -90,6 +99,19 @@ void main() {
canProceed: true,
),
);
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
transactionId: any(named: 'transactionId'),
approvalStatusId: any(named: 'approvalStatusId'),
requestedById: any(named: 'requestedById'),
statusCodes: any(named: 'statusCodes'),
includePending: any(named: 'includePending'),
includeHistories: any(named: 'includeHistories'),
includeSteps: any(named: 'includeSteps'),
),
).thenAnswer((_) async => createResult([sampleApproval]));
});
// fetch 메서드 관련 시나리오
@@ -102,6 +124,8 @@ void main() {
transactionId: any(named: 'transactionId'),
approvalStatusId: any(named: 'approvalStatusId'),
requestedById: any(named: 'requestedById'),
statusCodes: any(named: 'statusCodes'),
includePending: any(named: 'includePending'),
includeHistories: any(named: 'includeHistories'),
includeSteps: any(named: 'includeSteps'),
),
@@ -116,6 +140,29 @@ void main() {
expect(controller.errorMessage, isNull);
});
test('전체 상태 필터는 include_pending을 true로 전달한다', () async {
await controller.fetch();
final captured = verify(
() => repository.list(
page: 1,
pageSize: 20,
transactionId: null,
approvalStatusId: null,
requestedById: null,
statusCodes: captureAny(named: 'statusCodes'),
includePending: captureAny(named: 'includePending'),
includeHistories: false,
includeSteps: false,
),
).captured;
final includePending = captured[0] as bool;
final statusCodes = captured[1] as List<String>?;
expect(includePending, isTrue);
expect(statusCodes, isNull);
});
// 검색어/상태/기간 필터가 Repository 호출에 반영되는지 확인한다.
test('필터 전달을 검증한다', () async {
controller.updateTransactionFilter(55);
@@ -128,17 +175,52 @@ void main() {
await controller.fetch(page: 3);
verify(
final captured = verify(
() => repository.list(
page: 3,
pageSize: 20,
transactionId: 55,
approvalStatusId: null,
requestedById: 77,
statusCodes: captureAny(named: 'statusCodes'),
includePending: captureAny(named: 'includePending'),
includeHistories: false,
includeSteps: false,
),
).called(1);
).captured;
final includePending = captured[0] as bool;
final statusCodes = captured[1] as List<String>?;
expect(includePending, isFalse);
expect(listEquals(statusCodes, const ['approved']), isTrue);
});
test('대기 상태 필터는 초안·상신 상태 코드를 전달한다', () async {
controller.updateStatusFilter(ApprovalStatusFilter.pending);
await controller.fetch();
final captured = verify(
() => repository.list(
page: 1,
pageSize: 20,
transactionId: null,
approvalStatusId: null,
requestedById: null,
statusCodes: captureAny(named: 'statusCodes'),
includePending: captureAny(named: 'includePending'),
includeHistories: false,
includeSteps: false,
),
).captured;
final includePending = captured[0] as bool;
final statusCodes = captured[1] as List<String>?;
expect(includePending, isFalse);
expect(
listEquals(statusCodes, const ['draft', 'submitted', 'in_progress']),
isTrue,
);
});
// Repository 오류 발생 시 errorMessage가 설정된다.
@@ -150,6 +232,8 @@ void main() {
transactionId: any(named: 'transactionId'),
approvalStatusId: any(named: 'approvalStatusId'),
requestedById: any(named: 'requestedById'),
statusCodes: any(named: 'statusCodes'),
includePending: any(named: 'includePending'),
includeHistories: any(named: 'includeHistories'),
includeSteps: any(named: 'includeSteps'),
),
@@ -187,17 +271,24 @@ void main() {
ctrl.updateStatusFilter(ApprovalStatusFilter.approved);
await ctrl.fetch();
verify(
final captured = verify(
() => repository.list(
page: 1,
pageSize: 20,
transactionId: null,
approvalStatusId: 5,
requestedById: null,
statusCodes: captureAny(named: 'statusCodes'),
includePending: captureAny(named: 'includePending'),
includeHistories: false,
includeSteps: false,
),
).called(1);
).captured;
final includePending = captured[0] as bool;
final statusCodes = captured[1] as List<String>?;
expect(includePending, isFalse);
expect(listEquals(statusCodes, const ['approved']), isTrue);
});
});
@@ -211,17 +302,17 @@ void main() {
),
).thenAnswer((_) async => sampleApproval);
await controller.selectApproval(1);
await controller.selectApproval(sampleApproval.id!);
expect(controller.selected, isNotNull);
verify(
() => repository.fetchDetail(
1,
sampleApproval.id!,
includeSteps: true,
includeHistories: true,
),
).called(1);
verify(() => repository.canProceed(1)).called(1);
verify(() => repository.canProceed(sampleApproval.id!)).called(1);
expect(controller.canProceedSelected, isTrue);
});
@@ -234,7 +325,7 @@ void main() {
),
).thenThrow(Exception('detail fail'));
await controller.selectApproval(1);
await controller.selectApproval(sampleApproval.id!);
expect(controller.errorMessage, isNotNull);
});
@@ -392,6 +483,7 @@ void main() {
await controller.loadActionOptions(force: true);
await controller.fetch();
expect(controller.result, isNotNull);
await controller.selectApproval(sampleApproval.id!);
final success = await controller.performStepAction(
@@ -400,8 +492,12 @@ void main() {
);
expect(success, isTrue);
expect(controller.selected?.status.name, '승인');
expect(controller.result?.items.first.status.name, '승인');
expect(controller.selected?.status.id, updatedApproval.status.id);
expect(controller.result, isNotNull);
expect(
controller.result!.items.first.status.id,
updatedApproval.status.id,
);
expect(controller.isPerformingAction, isFalse);
verify(() => repository.performStepAction(any())).called(1);
});
@@ -616,6 +712,165 @@ void main() {
});
});
test('cacheSubmissionDraft와 consumeSubmissionDraft가 초안을 관리한다', () {
final draft = ApprovalSubmissionInput(
statusId: 1,
requesterId: 5,
steps: const [],
);
controller.cacheSubmissionDraft(draft);
expect(controller.hasSubmissionDraft, isTrue);
expect(controller.submissionDraft, same(draft));
final restored = controller.consumeSubmissionDraft();
expect(restored, same(draft));
expect(controller.hasSubmissionDraft, isFalse);
});
test('clearSubmissionDraft는 저장된 초안을 제거한다', () {
controller.cacheSubmissionDraft(
ApprovalSubmissionInput(statusId: 1, requesterId: 2, steps: const []),
);
controller.clearSubmissionDraft();
expect(controller.submissionDraft, isNull);
expect(controller.hasSubmissionDraft, isFalse);
});
test('cacheSubmissionDraft는 서버 초안을 저장한다', () async {
final saveUseCase = _MockSaveApprovalDraftUseCase();
final detail = ApprovalDraftDetail(
id: 99,
requesterId: 5,
savedAt: DateTime.utc(2025, 1, 1),
payload: ApprovalDraftPayload(steps: const []),
);
when(() => saveUseCase.call(any())).thenAnswer((_) async => detail);
final controllerWithSave = ApprovalController(
approvalRepository: repository,
templateRepository: templateRepository,
saveDraftUseCase: saveUseCase,
);
final draft = ApprovalSubmissionInput(
statusId: 3,
requesterId: 5,
transactionId: 10,
steps: [ApprovalStepAssignmentItem(stepOrder: 1, approverId: 7)],
);
controllerWithSave.cacheSubmissionDraft(draft);
await Future<void>.delayed(Duration.zero);
final captured = verify(() => saveUseCase.call(captureAny())).captured;
expect(captured, isNotEmpty);
final input = captured.first as ApprovalDraftSaveInput;
expect(input.requesterId, 5);
expect(input.transactionId, 10);
expect(input.statusId, 3);
expect(input.sessionKey, 'approval_submission_5');
});
test('restoreSubmissionDraft는 서버 초안을 불러온다', () async {
final listUseCase = _MockListApprovalDraftsUseCase();
final getUseCase = _MockGetApprovalDraftUseCase();
final summary = ApprovalDraftSummary(
id: 77,
requesterId: 5,
status: ApprovalDraftStatus.active,
savedAt: DateTime.utc(2025, 1, 3),
sessionKey: 'approval_submission_5',
stepCount: 1,
);
when(() => listUseCase.call(any())).thenAnswer(
(_) async => PaginatedResult<ApprovalDraftSummary>(
items: [summary],
page: 1,
pageSize: 10,
total: 1,
),
);
final detail = ApprovalDraftDetail(
id: summary.id,
requesterId: 5,
savedAt: DateTime.utc(2025, 1, 3),
payload: ApprovalDraftPayload(
templateId: 8,
metadata: const {
'_client_state': {'status_id': 6},
},
steps: [ApprovalDraftStep(stepOrder: 1, approverId: 9)],
),
);
when(
() => getUseCase.call(id: summary.id, requesterId: 5),
).thenAnswer((_) async => detail);
final controllerWithDrafts = ApprovalController(
approvalRepository: repository,
templateRepository: templateRepository,
listDraftsUseCase: listUseCase,
getDraftUseCase: getUseCase,
);
final restored = await controllerWithDrafts.restoreSubmissionDraft(
requesterId: 5,
);
expect(restored, isNotNull);
expect(restored!.statusId, 6);
expect(restored.steps, hasLength(1));
expect(controllerWithDrafts.submissionDraft, isNotNull);
verify(() => listUseCase.call(any())).called(1);
verify(() => getUseCase.call(id: summary.id, requesterId: 5)).called(1);
});
test('statusLabel은 draft 라벨을 반환한다', () {
final label = controller.statusLabel(ApprovalStatusFilter.draft);
expect(label, '임시저장');
});
test('목록에서 사라진 결재는 선택 상태를 해제한다', () async {
when(
() => repository.fetchDetail(
any(),
includeSteps: any(named: 'includeSteps'),
includeHistories: any(named: 'includeHistories'),
),
).thenAnswer((_) async => sampleApproval);
await controller.fetch();
await controller.selectApproval(sampleApproval.id!);
expect(controller.selected, isNotNull);
expect(controller.proceedStatus, isNotNull);
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
transactionId: any(named: 'transactionId'),
approvalStatusId: any(named: 'approvalStatusId'),
requestedById: any(named: 'requestedById'),
statusCodes: any(named: 'statusCodes'),
includePending: any(named: 'includePending'),
includeHistories: any(named: 'includeHistories'),
includeSteps: any(named: 'includeSteps'),
),
).thenAnswer((_) async => createResult(const []));
await controller.fetch();
expect(controller.selected, isNull);
expect(controller.proceedStatus, isNull);
});
test('필터 초기화', () {
controller.updateTransactionFilter(42);
controller.updateStatusFilter(ApprovalStatusFilter.rejected);

View File

@@ -0,0 +1,219 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart';
void main() {
ApprovalRequestParticipant buildParticipant(int id) {
return ApprovalRequestParticipant(
id: id,
name: '사용자$id',
employeeNo: 'EMP$id',
);
}
group('ApprovalRequestController', () {
late ApprovalRequestController controller;
setUp(() {
controller = ApprovalRequestController();
});
test('addStep 성공 시 최종 승인자로 바인딩된다', () {
final approver = buildParticipant(10);
final added = controller.addStep(approver: approver, note: '검토 필요');
expect(added, isTrue);
expect(controller.steps, hasLength(1));
expect(controller.steps.first.stepOrder, 1);
expect(controller.finalApprover?.id, approver.id);
expect(controller.errorMessage, isNull);
});
test('최대 단계 수를 초과하면 추가를 거부한다', () {
for (var i = 0; i < controller.maxSteps; i++) {
final success = controller.addStep(approver: buildParticipant(i + 1));
expect(success, isTrue);
}
final result = controller.addStep(approver: buildParticipant(999));
expect(result, isFalse);
expect(controller.errorMessage, isNotNull);
expect(controller.steps, hasLength(controller.maxSteps));
});
test('동일한 승인자를 중복 추가할 수 없다', () {
final approver = buildParticipant(3);
controller.addStep(approver: approver);
final duplicated = controller.addStep(approver: approver);
expect(duplicated, isFalse);
expect(controller.errorMessage, isNotNull);
expect(controller.steps, hasLength(1));
});
test('상신자는 승인자로 추가할 수 없다', () {
final requester = buildParticipant(7);
controller.setRequester(requester);
final added = controller.addStep(approver: requester);
expect(added, isFalse);
expect(controller.steps, isEmpty);
expect(controller.errorMessage, isNotNull);
});
test('updateStep에서 승인자 중복을 감지한다', () {
controller
..addStep(approver: buildParticipant(1))
..addStep(approver: buildParticipant(2));
final updated = controller.updateStep(1, approver: buildParticipant(1));
expect(updated, isFalse);
expect(controller.steps[1].approver.id, 2);
expect(controller.errorMessage, isNotNull);
});
test('removeStepAt 호출 시 순번을 재정렬한다', () {
controller
..addStep(approver: buildParticipant(1))
..addStep(approver: buildParticipant(2))
..addStep(approver: buildParticipant(3));
controller.removeStepAt(1);
expect(controller.steps, hasLength(2));
expect(controller.steps.first.stepOrder, 1);
expect(controller.steps.last.stepOrder, 2);
});
test('moveStep 호출 시 순서를 변경한다', () {
controller
..addStep(approver: buildParticipant(1))
..addStep(approver: buildParticipant(2))
..addStep(approver: buildParticipant(3));
controller.moveStep(2, 0);
expect(controller.steps.first.approver.id, 3);
expect(controller.steps.first.stepOrder, 1);
expect(controller.steps.last.approver.id, 2);
expect(controller.steps.last.stepOrder, 3);
});
test('setFinalApprover는 단계가 없을 때 새 단계를 추가한다', () {
final approver = buildParticipant(55);
final result = controller.setFinalApprover(approver);
expect(result, isTrue);
expect(controller.steps, hasLength(1));
expect(controller.finalApprover?.id, approver.id);
});
test('buildTransactionApprovalInput은 필수 정보를 누락하면 예외를 던진다', () {
controller.addStep(approver: buildParticipant(1));
expect(
() => controller.buildTransactionApprovalInput(),
throwsStateError,
);
});
test('buildTransactionApprovalInput은 요청 데이터를 변환한다', () {
final requester = buildParticipant(77);
controller.setRequester(requester);
controller
..addStep(approver: buildParticipant(101), note: '검토')
..addStep(approver: buildParticipant(102));
final approvalInput = controller.buildTransactionApprovalInput(
approvalStatusId: 9,
title: '입고 결재',
summary: '재고 입고 승인 요청',
note: '긴급 승인 필요',
metadata: const {'priority': 'high'},
);
final payload = approvalInput.toJson();
expect(payload['requested_by_id'], requester.id);
expect(payload['approval_status_id'], 9);
expect(payload['final_approver_id'], 102);
expect(payload['title'], '입고 결재');
expect(payload['summary'], '재고 입고 승인 요청');
expect(payload['note'], '긴급 승인 필요');
final steps = payload['steps'] as List<dynamic>;
expect(steps, hasLength(2));
expect(steps.first['step_order'], 1);
expect(steps.first['approver_id'], 101);
expect(steps.first['note'], '검토');
expect(steps.last['step_order'], 2);
expect(steps.last['approver_id'], 102);
});
test('buildSubmissionInput 변환 시 finalApproverId를 채운다', () {
controller
..setRequester(buildParticipant(2))
..addStep(approver: buildParticipant(10))
..addStep(approver: buildParticipant(11), note: '최종 검토');
final submission = controller.buildSubmissionInput(statusId: 4);
expect(submission.requesterId, 2);
expect(submission.finalApproverId, 11);
expect(submission.steps, hasLength(2));
});
test('applyTemplateSteps는 전달된 순서를 유지한다', () {
final steps = [
ApprovalRequestStep(stepOrder: 3, approver: buildParticipant(90)),
ApprovalRequestStep(stepOrder: 9, approver: buildParticipant(91)),
];
controller.applyTemplateSteps(steps);
expect(controller.steps.first.stepOrder, 1);
expect(controller.steps.last.stepOrder, 2);
expect(controller.finalApproverId, 91);
});
test('중복 승인자가 있는 상태에서 빌드 시 예외가 발생한다', () {
controller.setRequester(buildParticipant(5));
controller.applyTemplateSteps([
ApprovalRequestStep(stepOrder: 1, approver: buildParticipant(8)),
ApprovalRequestStep(stepOrder: 2, approver: buildParticipant(8)),
]);
expect(
() => controller.buildSubmissionInput(statusId: 1),
throwsStateError,
);
});
test('상신자와 동일한 승인자가 포함된 템플릿은 적용되지 않는다', () {
final requester = buildParticipant(20);
controller.setRequester(requester);
controller.applyTemplateSteps([
ApprovalRequestStep(stepOrder: 1, approver: requester),
ApprovalRequestStep(stepOrder: 2, approver: buildParticipant(21)),
]);
expect(controller.steps, isEmpty);
expect(controller.errorMessage, isNotNull);
});
test('상신자 변경 시 승인자와 겹치면 오류가 발생한다', () {
controller
..addStep(approver: buildParticipant(40))
..addStep(approver: buildParticipant(41));
controller.setRequester(buildParticipant(41));
expect(controller.requester?.id, 41);
expect(controller.errorMessage, isNotNull);
});
});
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart';
import 'package:superport_v2/features/approvals/request/presentation/utils/approval_form_initializer.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
void main() {
group('ApprovalFormInitializer.populate', () {
test('초안 저장본이 있으면 상신자와 단계를 복구한다', () {
final controller = ApprovalRequestController();
final draft = StockTransactionApprovalInput(
requestedById: 101,
steps: [
ApprovalStepAssignmentItem(stepOrder: 1, approverId: 104),
ApprovalStepAssignmentItem(stepOrder: 2, approverId: 201),
],
);
ApprovalFormInitializer.populate(controller: controller, draft: draft);
expect(controller.requester?.id, equals(101));
expect(controller.steps.length, equals(2));
expect(controller.steps.first.approver.id, equals(104));
expect(controller.steps.last.approver.id, equals(201));
});
test('카탈로그에 없는 승인자는 복구 대상에서 제외한다', () {
final controller = ApprovalRequestController();
final draft = StockTransactionApprovalInput(
requestedById: 101,
steps: [
ApprovalStepAssignmentItem(stepOrder: 1, approverId: 104),
ApprovalStepAssignmentItem(stepOrder: 2, approverId: 9999),
],
);
ApprovalFormInitializer.populate(controller: controller, draft: draft);
expect(controller.steps.length, equals(1));
expect(controller.steps.first.approver.id, equals(104));
});
});
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart';
import 'package:superport_v2/features/approvals/request/presentation/widgets/approval_step_configurator.dart';
Widget _buildTestApp(Widget child) {
return MaterialApp(
home: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
),
child: Scaffold(body: child),
),
);
}
ApprovalRequestParticipant _participant(int id, String name) {
return ApprovalRequestParticipant(id: id, name: name, employeeNo: 'EMP$id');
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('요약 섹션에 상신자와 단계 정보가 표시된다', (tester) async {
final controller = ApprovalRequestController();
controller.setRequester(_participant(1, '상신자'));
controller.addStep(approver: _participant(2, '1차 승인자'));
controller.addStep(approver: _participant(3, '최종 승인자'));
await tester.pumpWidget(
_buildTestApp(ApprovalStepConfigurator(controller: controller)),
);
await tester.pumpAndSettle();
expect(find.textContaining('상신자: 상신자'), findsOneWidget);
expect(find.textContaining('최종 승인자: 최종 승인자'), findsOneWidget);
expect(find.textContaining('총 단계: 2개'), findsOneWidget);
});
testWidgets('편집 버튼을 누르면 구성 모달이 열린다', (tester) async {
final controller = ApprovalRequestController();
controller.setRequester(_participant(1, '사용자A'));
await tester.pumpWidget(
_buildTestApp(ApprovalStepConfigurator(controller: controller)),
);
await tester.pump();
await tester.tap(find.text('단계 구성 편집'));
await tester.pumpAndSettle();
expect(find.text('결재 단계 구성'), findsWidgets);
expect(find.text('결재 단계 목록'), findsOneWidget);
});
}

View File

@@ -0,0 +1,136 @@
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/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart';
import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart';
import 'package:superport_v2/features/approvals/request/presentation/widgets/approval_template_picker.dart';
class _MockApprovalTemplateRepository extends Mock
implements ApprovalTemplateRepository {}
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();
late _MockApprovalTemplateRepository repository;
setUp(() {
repository = _MockApprovalTemplateRepository();
});
testWidgets('템플릿을 선택해 적용하면 단계가 컨트롤러에 반영된다', (tester) async {
final view = tester.view;
view.physicalSize = const Size(1280, 800);
view.devicePixelRatio = 1.0;
addTearDown(() {
view.resetPhysicalSize();
view.resetDevicePixelRatio();
});
final controller = ApprovalRequestController();
final template = ApprovalTemplate(
id: 1,
code: 'AP-RENTAL',
name: '입고 결재 템플릿',
isActive: true,
steps: const [],
updatedAt: DateTime.utc(2025, 1, 1),
createdBy: ApprovalTemplateAuthor(
id: 7,
employeeNo: 'EMP-7',
name: '관리자',
),
);
final templateDetail = template.copyWith(
steps: [
ApprovalTemplateStep(
id: 10,
stepOrder: 1,
approver: ApprovalTemplateApprover(
id: 101,
employeeNo: 'EMP-101',
name: '1차 승인자',
),
note: '재고 확인',
),
ApprovalTemplateStep(
id: 11,
stepOrder: 2,
approver: ApprovalTemplateApprover(
id: 102,
employeeNo: 'EMP-102',
name: '최종 승인자',
),
note: '승인 처리',
),
],
);
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<ApprovalTemplate>(
items: [template],
page: 1,
pageSize: 30,
total: 1,
),
);
when(
() => repository.fetchDetail(
template.id,
includeSteps: any(named: 'includeSteps'),
),
).thenAnswer((_) async => templateDetail);
final applied = <ApprovalTemplate>[];
controller.setTemplateSnapshot(
ApprovalTemplateSnapshot(
templateId: template.id,
updatedAt: template.updatedAt,
),
);
await tester.pumpWidget(
_buildApp(
ApprovalTemplatePicker(
controller: controller,
repository: repository,
onTemplateApplied: applied.add,
),
),
);
await tester.pump();
await tester.pumpAndSettle();
final applyButton = find.widgetWithText(ShadButton, '템플릿 적용');
expect(tester.widget<ShadButton>(applyButton).onPressed, isNotNull);
await tester.tap(applyButton);
await tester.pumpAndSettle();
expect(controller.steps, hasLength(2));
expect(controller.steps.first.approver.name, '1차 승인자');
expect(controller.steps.last.approver.name, '최종 승인자');
expect(applied.single.id, templateDetail.id);
});
}

View File

@@ -2,13 +2,26 @@ 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/data/dtos/approval_template_dto.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_template.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart';
import 'package:superport_v2/features/approvals/domain/usecases/apply_approval_template_use_case.dart';
import 'package:superport_v2/features/approvals/domain/usecases/save_approval_template_use_case.dart';
import 'package:superport_v2/features/approvals/template/presentation/controllers/approval_template_controller.dart';
import '../../../../../helpers/fixture_loader.dart';
class _MockApprovalTemplateRepository extends Mock
implements ApprovalTemplateRepository {}
class _MockSaveApprovalTemplateUseCase extends Mock
implements SaveApprovalTemplateUseCase {}
class _MockApplyApprovalTemplateUseCase extends Mock
implements ApplyApprovalTemplateUseCase {}
class _FakeTemplateInput extends Fake implements ApprovalTemplateInput {}
class _FakeStepInput extends Fake implements ApprovalTemplateStepInput {}
@@ -16,19 +29,12 @@ class _FakeStepInput extends Fake implements ApprovalTemplateStepInput {}
void main() {
late ApprovalTemplateController controller;
late _MockApprovalTemplateRepository repository;
late _MockSaveApprovalTemplateUseCase saveUseCase;
late _MockApplyApprovalTemplateUseCase applyUseCase;
final sampleTemplate = ApprovalTemplate(
id: 1,
code: 'AP_INBOUND',
name: '입고 결재 기본',
description: '입고 2단계',
note: '기본 템플릿',
isActive: true,
createdBy: null,
createdAt: DateTime(2024, 4, 1, 9),
updatedAt: DateTime(2024, 4, 2, 9),
steps: const [],
);
final sampleTemplate = ApprovalTemplateDto.fromJson(
loadJsonFixture('approvals/approval_template_sample.json'),
).toEntity();
PaginatedResult<ApprovalTemplate> createResult(List<ApprovalTemplate> items) {
return PaginatedResult<ApprovalTemplate>(
@@ -39,6 +45,36 @@ void main() {
);
}
ApprovalFlow createFlow() {
final status = ApprovalStatus(id: 10, name: '대기');
final requester = ApprovalRequester(
id: 99,
employeeNo: 'EMP099',
name: '상신자',
);
final approver = ApprovalApprover(
id: 100,
employeeNo: 'EMP100',
name: '승인자',
);
final step = ApprovalStep(
stepOrder: 1,
approver: approver,
status: status,
assignedAt: DateTime(2024, 4, 1),
);
final approval = Approval(
id: 7,
approvalNo: 'APP-20240401-0001',
transactionNo: 'TRX-001',
status: status,
requester: requester,
requestedAt: DateTime(2024, 4, 1),
steps: [step],
);
return ApprovalFlow(approval: approval);
}
setUpAll(() {
registerFallbackValue(_FakeTemplateInput());
registerFallbackValue(_FakeStepInput());
@@ -47,7 +83,13 @@ void main() {
setUp(() {
repository = _MockApprovalTemplateRepository();
controller = ApprovalTemplateController(repository: repository);
saveUseCase = _MockSaveApprovalTemplateUseCase();
applyUseCase = _MockApplyApprovalTemplateUseCase();
controller = ApprovalTemplateController(
repository: repository,
saveTemplateUseCase: saveUseCase,
applyTemplateUseCase: applyUseCase,
);
});
group('fetch', () {
@@ -62,11 +104,12 @@ void main() {
).thenAnswer((_) async => createResult([sampleTemplate]));
});
test('목록을 조회한다', () async {
test('목록을 조회하고 버전을 캐시한다', () async {
await controller.fetch();
expect(controller.result?.items, isNotEmpty);
expect(controller.errorMessage, isNull);
expect(controller.versionOf(sampleTemplate.id), sampleTemplate.updatedAt);
});
test('필터를 전달한다', () async {
@@ -101,11 +144,14 @@ void main() {
});
});
test('create 성공목록 갱신', () async {
test('create 호출SaveUseCase를 사용하고 버전을 기록한다', () async {
when(
() => repository.create(any(), steps: any(named: 'steps')),
() => saveUseCase.call(
templateId: any(named: 'templateId'),
input: any(named: 'input'),
steps: any(named: 'steps'),
),
).thenAnswer((_) async => sampleTemplate);
when(
() => repository.list(
page: any(named: 'page'),
@@ -121,16 +167,24 @@ void main() {
);
expect(created, isNotNull);
expect(controller.versionOf(sampleTemplate.id), sampleTemplate.updatedAt);
verify(
() => repository.create(any(), steps: any(named: 'steps')),
() => saveUseCase.call(
templateId: any(named: 'templateId'),
input: any(named: 'input'),
steps: any(named: 'steps'),
),
).called(1);
});
test('update 성공 시 현재 페이지 갱신', () async {
test('save는 update 경로에서도 유즈케이스를 사용한다', () async {
when(
() => repository.update(any(), any(), steps: any(named: 'steps')),
() => saveUseCase.call(
templateId: any(named: 'templateId'),
input: any(named: 'input'),
steps: any(named: 'steps'),
),
).thenAnswer((_) async => sampleTemplate);
when(
() => repository.list(
page: any(named: 'page'),
@@ -140,13 +194,44 @@ void main() {
),
).thenAnswer((_) async => createResult([sampleTemplate]));
controller.updateQuery('AP');
await controller.update(1, ApprovalTemplateInput(name: '입고 결재 수정'), [
ApprovalTemplateStepInput(stepOrder: 1, approverId: 33),
]);
await controller.save(
templateId: 1,
input: ApprovalTemplateInput(name: '수정 템플릿'),
steps: const [],
);
verify(
() => repository.update(any(), any(), steps: any(named: 'steps')),
() => saveUseCase.call(
templateId: 1,
input: any(named: 'input'),
steps: any(named: 'steps'),
),
).called(1);
});
test('유즈케이스 미주입 시 저장소를 직접 호출한다', () async {
final fallbackController = ApprovalTemplateController(
repository: repository,
);
when(
() => repository.create(any(), steps: any(named: 'steps')),
).thenAnswer((_) async => sampleTemplate);
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
),
).thenAnswer((_) async => createResult([sampleTemplate]));
await fallbackController.create(
ApprovalTemplateInput(code: 'AP', name: '템플릿'),
const [],
);
verify(
() => repository.create(any(), steps: any(named: 'steps')),
).called(1);
});
@@ -167,7 +252,7 @@ void main() {
verify(() => repository.delete(1)).called(1);
});
test('restore 성공 시 템플릿을 반환한다', () async {
test('restore 성공 시 템플릿을 반환하고 버전을 갱신한다', () async {
when(
() => repository.restore(any()),
).thenAnswer((_) async => sampleTemplate);
@@ -183,6 +268,58 @@ void main() {
final restored = await controller.restore(1);
expect(restored, isNotNull);
verify(() => repository.restore(1)).called(1);
expect(controller.versionOf(sampleTemplate.id), sampleTemplate.updatedAt);
});
test('applyToApproval은 유즈케이스를 호출하고 버전을 갱신한다', () async {
when(
() => applyUseCase.call(
approvalId: any(named: 'approvalId'),
templateId: any(named: 'templateId'),
),
).thenAnswer((_) async => createFlow());
when(
() => repository.fetchDetail(
any(),
includeSteps: any(named: 'includeSteps'),
),
).thenAnswer((_) async => sampleTemplate);
final flow = await controller.applyToApproval(
approvalId: 10,
templateId: sampleTemplate.id,
);
expect(flow, isNotNull);
expect(controller.isApplyingTemplate, isFalse);
expect(controller.versionOf(sampleTemplate.id), sampleTemplate.updatedAt);
verify(
() => applyUseCase.call(approvalId: 10, templateId: sampleTemplate.id),
).called(1);
});
test('isTemplateStale은 최신 버전을 판단한다', () async {
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
),
).thenAnswer((_) async => createResult([sampleTemplate]));
await controller.fetch();
expect(
controller.isTemplateStale(sampleTemplate.id, sampleTemplate.updatedAt),
isFalse,
);
expect(
controller.isTemplateStale(
sampleTemplate.id,
sampleTemplate.updatedAt!.add(const Duration(minutes: 10)),
),
isTrue,
);
});
}

View File

@@ -7,12 +7,17 @@ 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_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/apply_approval_template_use_case.dart';
import 'package:superport_v2/features/approvals/domain/usecases/save_approval_template_use_case.dart';
import 'package:superport_v2/features/approvals/template/presentation/pages/approval_template_page.dart';
class _MockApprovalTemplateRepository extends Mock
implements ApprovalTemplateRepository {}
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
class _FakeTemplateInput extends Fake implements ApprovalTemplateInput {}
class _FakeTemplateStepInput extends Fake
@@ -56,13 +61,27 @@ void main() {
group('플래그 On', () {
late _MockApprovalTemplateRepository repository;
late _MockApprovalRepository approvalRepository;
setUp(() {
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
repository = _MockApprovalTemplateRepository();
approvalRepository = _MockApprovalRepository();
GetIt.I.registerLazySingleton<ApprovalTemplateRepository>(
() => repository,
);
GetIt.I.registerLazySingleton<ApprovalRepository>(
() => approvalRepository,
);
GetIt.I.registerLazySingleton<SaveApprovalTemplateUseCase>(
() => SaveApprovalTemplateUseCase(repository: repository),
);
GetIt.I.registerLazySingleton<ApplyApprovalTemplateUseCase>(
() => ApplyApprovalTemplateUseCase(
templateRepository: repository,
approvalRepository: approvalRepository,
),
);
});
ApprovalTemplate buildTemplate({bool isActive = true}) {
@@ -114,6 +133,7 @@ void main() {
expect(find.text('AP_INBOUND'), findsOneWidget);
expect(find.text('입고 템플릿'), findsOneWidget);
expect(find.textContaining('1. 최승인'), findsOneWidget);
verify(
() =>
@@ -188,6 +208,54 @@ void main() {
expect(find.text('템플릿 "신규 템플릿"을 생성했습니다.'), findsOneWidget);
});
testWidgets('보기 버튼을 눌러 템플릿 단계를 미리본다', (tester) async {
final template = buildTemplate();
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<ApprovalTemplate>(
items: [template],
page: 1,
pageSize: 20,
total: 1,
),
);
when(
() => repository.fetchDetail(template.id, includeSteps: true),
).thenAnswer((_) async => template);
await tester.pumpWidget(_buildApp(const ApprovalTemplatePage()));
await tester.pump();
await tester.pumpAndSettle();
final previewFinder = find.text('보기', skipOffstage: false);
await tester.dragUntilVisible(
previewFinder,
find.text(template.name),
const Offset(-200, 0),
);
await tester.pumpAndSettle();
await tester.tap(previewFinder);
await tester.pump();
await tester.pumpAndSettle();
expect(find.text(template.name), findsWidgets);
expect(find.textContaining('사번 E001'), findsOneWidget);
verify(
() => repository.fetchDetail(template.id, includeSteps: true),
).called(1);
});
testWidgets('수정 플로우에서 fetchDetail 후 update를 호출한다', (tester) async {
final activeTemplate = buildTemplate();

View File

@@ -4,6 +4,11 @@ import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_error.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_draft.dart';
import 'package:superport_v2/features/approvals/domain/usecases/get_approval_draft_use_case.dart';
import 'package:superport_v2/features/approvals/domain/usecases/list_approval_drafts_use_case.dart';
import 'package:superport_v2/features/approvals/domain/usecases/save_approval_draft_use_case.dart';
import 'package:superport_v2/features/inventory/inbound/presentation/controllers/inbound_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';
@@ -23,20 +28,14 @@ class _MockTransactionLineRepository extends Mock
class _MockTransactionCustomerRepository extends Mock
implements TransactionCustomerRepository {}
class _FakeStockTransactionCreateInput extends Fake
implements StockTransactionCreateInput {}
class _MockSaveApprovalDraftUseCase extends Mock
implements SaveApprovalDraftUseCase {}
class _FakeStockTransactionUpdateInput extends Fake
implements StockTransactionUpdateInput {}
class _MockGetApprovalDraftUseCase extends Mock
implements GetApprovalDraftUseCase {}
class _FakeStockTransactionListFilter extends Fake
implements StockTransactionListFilter {}
class _FakeTransactionCustomerCreateInput extends Fake
implements TransactionCustomerCreateInput {}
class _FakeTransactionCustomerUpdateInput extends Fake
implements TransactionCustomerUpdateInput {}
class _MockListApprovalDraftsUseCase extends Mock
implements ListApprovalDraftsUseCase {}
void main() {
group('InboundController', () {
@@ -47,13 +46,31 @@ void main() {
late InboundController controller;
setUpAll(() {
registerFallbackValue(_FakeStockTransactionCreateInput());
registerFallbackValue(_FakeStockTransactionUpdateInput());
registerFallbackValue(_FakeStockTransactionListFilter());
registerFallbackValue(_FakeTransactionCustomerCreateInput());
registerFallbackValue(_FakeTransactionCustomerUpdateInput());
registerFallbackValue(
StockTransactionCreateInput(
transactionTypeId: 0,
transactionStatusId: 0,
warehouseId: 0,
transactionDate: DateTime(2000, 1, 1),
createdById: 0,
approval: StockTransactionApprovalInput(requestedById: 0),
),
);
registerFallbackValue(
StockTransactionUpdateInput(transactionStatusId: 0),
);
registerFallbackValue(StockTransactionListFilter());
registerFallbackValue(TransactionCustomerCreateInput(customerId: 0));
registerFallbackValue(TransactionCustomerUpdateInput(id: 0));
registerFallbackValue(<TransactionCustomerCreateInput>[]);
registerFallbackValue(<TransactionCustomerUpdateInput>[]);
registerFallbackValue(const ApprovalDraftListFilter(requesterId: 0));
registerFallbackValue(
ApprovalDraftSaveInput(
requesterId: 0,
steps: [ApprovalDraftStep(stepOrder: 0, approverId: 0)],
),
);
});
setUp(() {
@@ -81,6 +98,7 @@ void main() {
warehouseId: 3,
transactionDate: DateTime(2024, 3, 1),
createdById: 9,
approval: StockTransactionApprovalInput(requestedById: 9),
);
final record = await controller.createTransaction(
@@ -91,6 +109,7 @@ void main() {
expect(record.id, equals(transaction.id));
expect(controller.records.length, equals(1));
expect(controller.records.first.id, equals(transaction.id));
expect(controller.approvalDraft?.requestedById, equals(9));
verify(() => transactionRepository.create(any())).called(1);
});
@@ -111,6 +130,7 @@ void main() {
warehouseId: 3,
transactionDate: DateTime(2024, 3, 1),
createdById: 9,
approval: StockTransactionApprovalInput(requestedById: 9),
),
refreshAfter: false,
);
@@ -149,6 +169,7 @@ void main() {
warehouseId: 3,
transactionDate: DateTime(2024, 3, 1),
createdById: 9,
approval: StockTransactionApprovalInput(requestedById: 9),
),
refreshAfter: false,
);
@@ -197,6 +218,107 @@ void main() {
verify(() => customerRepository.addCustomers(42, any())).called(1);
});
test('updateApprovalDraft와 clearApprovalDraft가 초안을 관리한다', () {
final approval = StockTransactionApprovalInput(
requestedById: 55,
approvalStatusId: 7,
note: '메모',
);
controller.updateApprovalDraft(approval);
expect(controller.approvalDraft?.requestedById, equals(55));
expect(controller.approvalDraft?.approvalStatusId, equals(7));
controller.clearApprovalDraft();
expect(controller.approvalDraft, isNull);
});
test('updateApprovalDraft는 서버 초안을 동기화한다', () async {
final saveUseCase = _MockSaveApprovalDraftUseCase();
when(() => saveUseCase.call(any())).thenAnswer(
(_) async => ApprovalDraftDetail(
id: 1,
requesterId: 3,
savedAt: DateTime.utc(2025, 1, 1),
payload: ApprovalDraftPayload(steps: const []),
),
);
final controllerWithSave = InboundController(
transactionRepository: transactionRepository,
lineRepository: lineRepository,
customerRepository: customerRepository,
lookupRepository: lookupRepository,
saveDraftUseCase: saveUseCase,
);
final approval = StockTransactionApprovalInput(
requestedById: 3,
approvalStatusId: 2,
steps: [ApprovalStepAssignmentItem(stepOrder: 1, approverId: 4)],
);
controllerWithSave.updateApprovalDraft(approval);
await Future<void>.delayed(Duration.zero);
verify(() => saveUseCase.call(captureAny())).called(1);
});
test('loadApprovalDraftFromServer는 원격 초안을 반영한다', () async {
final listUseCase = _MockListApprovalDraftsUseCase();
final getUseCase = _MockGetApprovalDraftUseCase();
final summary = ApprovalDraftSummary(
id: 5,
requesterId: 3,
status: ApprovalDraftStatus.active,
savedAt: DateTime.utc(2025, 1, 2),
sessionKey: 'inventory_inbound_3',
stepCount: 1,
);
when(() => listUseCase.call(any())).thenAnswer(
(_) async => PaginatedResult<ApprovalDraftSummary>(
items: [summary],
page: 1,
pageSize: 10,
total: 1,
),
);
final detail = ApprovalDraftDetail(
id: summary.id,
requesterId: 3,
savedAt: DateTime.utc(2025, 1, 2),
payload: ApprovalDraftPayload(
metadata: const {
'_client_state': {'status_id': 6},
},
steps: [ApprovalDraftStep(stepOrder: 1, approverId: 9)],
),
);
when(
() => getUseCase.call(id: summary.id, requesterId: 3),
).thenAnswer((_) async => detail);
final controllerWithDrafts = InboundController(
transactionRepository: transactionRepository,
lineRepository: lineRepository,
customerRepository: customerRepository,
lookupRepository: lookupRepository,
getDraftUseCase: getUseCase,
listDraftsUseCase: listUseCase,
);
await controllerWithDrafts.loadApprovalDraftFromServer(requesterId: 3);
verify(() => listUseCase.call(any())).called(1);
verify(() => getUseCase.call(id: summary.id, requesterId: 3)).called(1);
final approval = controllerWithDrafts.approvalDraft;
expect(approval, isNotNull);
expect(approval!.requestedById, 3);
expect(approval.approvalStatusId, 6);
expect(approval.steps, hasLength(1));
});
test('submitTransaction은 refreshAfter가 true일 때 목록을 다시 불러온다', () async {
final filter = StockTransactionListFilter(transactionTypeId: 1);
final initial = _buildTransaction();

View File

@@ -21,7 +21,9 @@ void main() {
});
setUp(() {
registerInventoryTestStubs();
registerInventoryTestStubs(
const InventoryTestStubConfig(registerProductRepository: true),
);
});
tearDown(() async {
@@ -137,7 +139,9 @@ void main() {
matching: find.byType(EditableText),
);
await tester.enterText(firstProductInput, 'XR-5000');
await tester.pump();
await tester.pumpAndSettle();
await tester.tap(find.text('XR-5000').last);
await tester.pumpAndSettle();
final addLineButton = find.widgetWithText(ShadButton, '품목 추가');
await tester.ensureVisible(addLineButton);
@@ -152,12 +156,14 @@ void main() {
matching: find.byType(EditableText),
);
await tester.enterText(secondProductInput, 'XR-5000');
await tester.pump();
await tester.pumpAndSettle();
await tester.tap(find.text('XR-5000').last);
await tester.pumpAndSettle();
final saveButton = find.widgetWithText(ShadButton, '저장');
await tester.ensureVisible(saveButton);
await tester.tap(saveButton);
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('동일 제품이 중복되었습니다.'), findsOneWidget);
});

View File

@@ -21,15 +21,6 @@ class _MockTransactionLineRepository extends Mock
class _MockTransactionCustomerRepository extends Mock
implements TransactionCustomerRepository {}
class _FakeStockTransactionCreateInput extends Fake
implements StockTransactionCreateInput {}
class _FakeStockTransactionUpdateInput extends Fake
implements StockTransactionUpdateInput {}
class _FakeStockTransactionListFilter extends Fake
implements StockTransactionListFilter {}
void main() {
group('OutboundController', () {
late StockTransactionRepository transactionRepository;
@@ -39,9 +30,20 @@ void main() {
late OutboundController controller;
setUpAll(() {
registerFallbackValue(_FakeStockTransactionCreateInput());
registerFallbackValue(_FakeStockTransactionUpdateInput());
registerFallbackValue(_FakeStockTransactionListFilter());
registerFallbackValue(
StockTransactionCreateInput(
transactionTypeId: 0,
transactionStatusId: 0,
warehouseId: 0,
transactionDate: DateTime(2000, 1, 1),
createdById: 0,
approval: StockTransactionApprovalInput(requestedById: 0),
),
);
registerFallbackValue(
StockTransactionUpdateInput(transactionStatusId: 0),
);
registerFallbackValue(StockTransactionListFilter());
});
setUp(() {
@@ -70,12 +72,31 @@ void main() {
warehouseId: 3,
transactionDate: DateTime(2024, 4, 1),
createdById: 7,
approval: StockTransactionApprovalInput(requestedById: 7),
),
refreshAfter: false,
);
expect(record.id, equals(transaction.id));
expect(controller.records.first.id, equals(transaction.id));
expect(controller.approvalDraft?.requestedById, equals(7));
});
test('updateApprovalDraft와 clearApprovalDraft가 초안을 관리한다', () {
final draft = StockTransactionApprovalInput(
requestedById: 33,
approvalStatusId: 4,
note: '테스트',
);
controller.updateApprovalDraft(draft);
expect(controller.approvalDraft?.requestedById, equals(33));
expect(controller.approvalDraft?.approvalStatusId, equals(4));
controller.clearApprovalDraft();
expect(controller.approvalDraft, isNull);
});
test('completeTransaction은 레코드를 갱신하고 처리 상태를 추적한다', () async {
@@ -95,6 +116,7 @@ void main() {
warehouseId: 3,
transactionDate: DateTime(2024, 4, 1),
createdById: 7,
approval: StockTransactionApprovalInput(requestedById: 7),
),
refreshAfter: false,
);

View File

@@ -18,7 +18,9 @@ void main() {
});
setUp(() {
registerInventoryTestStubs();
registerInventoryTestStubs(
const InventoryTestStubConfig(registerProductRepository: true),
);
});
tearDown(() async {

View File

@@ -22,15 +22,6 @@ class _MockTransactionLineRepository extends Mock
class _MockTransactionCustomerRepository extends Mock
implements TransactionCustomerRepository {}
class _FakeStockTransactionCreateInput extends Fake
implements StockTransactionCreateInput {}
class _FakeStockTransactionUpdateInput extends Fake
implements StockTransactionUpdateInput {}
class _FakeStockTransactionListFilter extends Fake
implements StockTransactionListFilter {}
void main() {
group('RentalController', () {
late StockTransactionRepository transactionRepository;
@@ -40,9 +31,20 @@ void main() {
late RentalController controller;
setUpAll(() {
registerFallbackValue(_FakeStockTransactionCreateInput());
registerFallbackValue(_FakeStockTransactionUpdateInput());
registerFallbackValue(_FakeStockTransactionListFilter());
registerFallbackValue(
StockTransactionCreateInput(
transactionTypeId: 0,
transactionStatusId: 0,
warehouseId: 0,
transactionDate: DateTime(2000, 1, 1),
createdById: 0,
approval: StockTransactionApprovalInput(requestedById: 0),
),
);
registerFallbackValue(
StockTransactionUpdateInput(transactionStatusId: 0),
);
registerFallbackValue(StockTransactionListFilter());
});
setUp(() {
@@ -71,12 +73,31 @@ void main() {
warehouseId: 4,
transactionDate: DateTime(2024, 5, 1),
createdById: 5,
approval: StockTransactionApprovalInput(requestedById: 5),
),
refreshAfter: false,
);
expect(record.id, equals(transaction.id));
expect(controller.records.first.id, equals(transaction.id));
expect(controller.approvalDraft?.requestedById, equals(5));
});
test('updateApprovalDraft와 clearApprovalDraft가 초안을 관리한다', () {
final approval = StockTransactionApprovalInput(
requestedById: 44,
approvalStatusId: 9,
note: '대여 승인',
);
controller.updateApprovalDraft(approval);
expect(controller.approvalDraft?.requestedById, equals(44));
expect(controller.approvalDraft?.approvalStatusId, equals(9));
controller.clearApprovalDraft();
expect(controller.approvalDraft, isNull);
});
test('deleteTransaction은 레코드를 제거하고 처리 상태를 초기화한다', () async {
@@ -95,6 +116,7 @@ void main() {
warehouseId: 4,
transactionDate: DateTime(2024, 5, 1),
createdById: 5,
approval: StockTransactionApprovalInput(requestedById: 5),
),
refreshAfter: false,
);

View File

@@ -18,7 +18,9 @@ void main() {
});
setUp(() {
registerInventoryTestStubs();
registerInventoryTestStubs(
const InventoryTestStubConfig(registerProductRepository: true),
);
});
tearDown(() async {

View File

@@ -0,0 +1,125 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport_v2/features/inventory/transactions/data/dtos/stock_transaction_dto.dart';
void main() {
group('StockTransactionDto', () {
test('결재 상세 정보를 포함해 파싱한다', () {
final dto = StockTransactionDto.fromJson({
'id': 9001,
'transaction_no': 'TRX-202511100001',
'transaction_date': '2025-09-18',
'transaction_type': {'id': 1, 'name': '입고'},
'transaction_status': {'id': 1, 'name': '초안'},
'warehouse': {
'id': 1,
'warehouse_code': 'WH-001',
'warehouse_name': '1센터',
},
'created_by': {
'id': 7,
'employee_no': 'E20250001',
'employee_name': '김상신',
},
'lines': [
{
'id': 12001,
'line_no': 1,
'product': {
'id': 101,
'product_code': 'P100',
'product_name': '샘플',
},
'quantity': 10,
'unit_price': 1200,
},
],
'customers': [],
'approval': {
'id': 5001,
'approval_no': 'APP-202511100001',
'status': {
'id': 1,
'name': '대기',
'is_blocking_next': true,
'is_terminal': false,
},
'current_step': {
'id': 7001,
'step_order': 1,
'status': {
'id': 2,
'name': '진행중',
'is_blocking_next': true,
'is_terminal': false,
},
'approver': {'id': 21, 'employee_no': 'E2025002', 'name': '박검토'},
'assigned_at': '2025-09-18T06:05:00Z',
},
'requester': {'id': 7, 'employee_no': 'E20250001', 'name': '김상신'},
'requested_at': '2025-09-18T06:00:00Z',
'note': '입고 결재',
'template_name': '입고 결재 기본',
'steps': [
{
'id': 7201,
'step_order': 1,
'status': {
'id': 3,
'name': '승인',
'is_blocking_next': false,
'is_terminal': false,
},
'approver': {'id': 21, 'employee_no': 'E2025002', 'name': '박검토'},
'assigned_at': '2025-09-18T06:05:00Z',
'decided_at': '2025-09-18T06:10:00Z',
},
{
'id': 7202,
'step_order': 2,
'status': {
'id': 1,
'name': '대기',
'is_blocking_next': true,
'is_terminal': false,
},
'approver': {'id': 22, 'employee_no': 'E2025003', 'name': '이승인'},
'assigned_at': '2025-09-18T06:10:00Z',
},
],
'histories': [
{
'id': 93001,
'action': {'id': 1, 'name': '상신'},
'to_status': {
'id': 1,
'name': '대기',
'is_blocking_next': true,
'is_terminal': false,
},
'approver': {'id': 7, 'employee_no': 'E20250001', 'name': '김상신'},
'action_at': '2025-09-18T06:00:00Z',
},
],
'created_at': '2025-09-18T06:00:00Z',
'updated_at': '2025-09-18T06:05:00Z',
},
});
final entity = dto.toEntity();
expect(entity.approval, isNotNull);
final approval = entity.approval!;
expect(approval.approvalNo, 'APP-202511100001');
expect(approval.status.name, '대기');
expect(approval.currentStep?.stepOrder, 1);
expect(approval.currentStep?.approver.name, '박검토');
expect(approval.steps.length, 2);
expect(approval.histories.length, 1);
expect(approval.requester.name, '김상신');
expect(
approval.requestedAt.toUtc().toIso8601String(),
'2025-09-18T06:00:00.000Z',
);
});
});
}

View File

@@ -86,7 +86,9 @@ void main() {
GetIt.I.registerSingleton<AuthService>(authService);
when(() => authRepository.login(any())).thenAnswer((_) async => sampleSession);
when(
() => authRepository.login(any()),
).thenAnswer((_) async => sampleSession);
when(() => authRepository.refresh(any())).thenThrow(UnimplementedError());
});

View File

@@ -296,7 +296,7 @@ void main() {
_buildApp(
Center(
child: SizedBox(
width: 260,
width: 320,
child: CustomerPage(routeUri: Uri(path: '/masters/customers')),
),
),

View File

@@ -348,7 +348,7 @@ void main() {
_buildApp(
Center(
child: SizedBox(
width: 260,
width: 320,
child: ProductPage(routeUri: Uri(path: '/masters/products')),
),
),

View File

@@ -42,7 +42,9 @@ void main() {
expect(selectedId, equals(1));
final editableText = tester.widget<EditableText>(find.byType(EditableText));
final editableText = tester.widget<EditableText>(
find.byType(EditableText),
);
expect(editableText.controller.text, '테스트 제조사 (V001)');
});
});

View File

@@ -242,7 +242,7 @@ void main() {
_buildApp(
Center(
child: SizedBox(
width: 260,
width: 320,
child: VendorPage(routeUri: Uri(path: '/masters/vendors')),
),
),

View File

@@ -277,7 +277,7 @@ void main() {
_buildApp(
Center(
child: SizedBox(
width: 260,
width: 320,
child: WarehousePage(routeUri: Uri(path: '/masters/warehouses')),
),
),

View File

@@ -48,10 +48,7 @@ void main() {
await tester.pumpAndSettle();
expect(repo.attempts, 1);
expect(
find.text('창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'),
findsWidgets,
);
expect(find.text('창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'), findsWidgets);
await tester.tap(find.widgetWithText(ShadButton, '재시도'));
await tester.pumpAndSettle();

View File

@@ -0,0 +1,237 @@
{
"id": 51001,
"approval_no": "APP-202511100201",
"transaction": {
"id": 91001,
"transaction_no": "IN-20251110-0001"
},
"status": {
"id": 2,
"name": "진행중",
"color": "#3B82F6",
"is_blocking_next": true,
"is_terminal": false
},
"current_step": {
"id": 72003,
"step_order": 3,
"status": {
"id": 2,
"name": "진행중",
"is_blocking_next": true,
"is_terminal": false
},
"approver": {
"id": 104,
"employee_id": "E20250104",
"employee_no": "E20250104",
"name": "박팀장"
},
"assigned_at": "2025-09-18T07:10:00Z",
"decided_at": null,
"note": null
},
"requester": {
"id": 77,
"employee_id": "E20250077",
"employee_no": "E20250077",
"name": "김상신"
},
"requested_at": "2025-09-18T06:55:00Z",
"decided_at": null,
"note": "입고 5단계 결재",
"is_active": true,
"is_deleted": false,
"steps": [
{
"id": 72001,
"step_order": 1,
"status": {
"id": 3,
"name": "승인",
"is_blocking_next": false,
"is_terminal": false
},
"approver": {
"id": 101,
"employee_id": "E20250101",
"employee_no": "E20250101",
"name": "이검토"
},
"assigned_at": "2025-09-18T06:58:00Z",
"decided_at": "2025-09-18T06:59:30Z",
"note": "조건부 승인"
},
{
"id": 72002,
"step_order": 2,
"status": {
"id": 3,
"name": "승인",
"is_blocking_next": false,
"is_terminal": false
},
"approver": {
"id": 102,
"employee_id": "E20250102",
"employee_no": "E20250102",
"name": "최검수"
},
"assigned_at": "2025-09-18T07:00:00Z",
"decided_at": "2025-09-18T07:04:10Z",
"note": null
},
{
"id": 72003,
"step_order": 3,
"status": {
"id": 2,
"name": "진행중",
"is_blocking_next": true,
"is_terminal": false
},
"approver": {
"id": 104,
"employee_id": "E20250104",
"employee_no": "E20250104",
"name": "박팀장"
},
"assigned_at": "2025-09-18T07:10:00Z",
"decided_at": null,
"note": null
},
{
"id": 72004,
"step_order": 4,
"status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"approver": {
"id": 105,
"employee_id": "E20250105",
"employee_no": "E20250105",
"name": "정차장"
},
"assigned_at": "2025-09-18T07:10:00Z",
"decided_at": null,
"note": null
},
{
"id": 72005,
"step_order": 5,
"status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"approver": {
"id": 201,
"employee_id": "E20250201",
"employee_no": "E20250201",
"name": "한임원"
},
"assigned_at": "2025-09-18T07:10:00Z",
"decided_at": null,
"note": null
}
],
"histories": [
{
"id": 93001,
"action": {
"id": 1,
"name": "상신"
},
"from_status": null,
"to_status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"approver": {
"id": 77,
"employee_id": "E20250077",
"employee_no": "E20250077",
"name": "김상신"
},
"action_at": "2025-09-18T06:55:00Z",
"note": null
},
{
"id": 93002,
"action": {
"id": 2,
"name": "승인"
},
"from_status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"to_status": {
"id": 3,
"name": "승인",
"is_blocking_next": false,
"is_terminal": false
},
"approver": {
"id": 101,
"employee_id": "E20250101",
"employee_no": "E20250101",
"name": "이검토"
},
"action_at": "2025-09-18T06:59:30Z",
"note": "조건 수용"
},
{
"id": 93003,
"action": {
"id": 2,
"name": "승인"
},
"from_status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"to_status": {
"id": 3,
"name": "승인",
"is_blocking_next": false,
"is_terminal": false
},
"approver": {
"id": 102,
"employee_id": "E20250102",
"employee_no": "E20250102",
"name": "최검수"
},
"action_at": "2025-09-18T07:04:10Z",
"note": null
}
],
"created_at": "2025-09-18T06:55:00Z",
"updated_at": "2025-09-18T07:10:00Z",
"visibility": {
"allowed_roles": [
"requester",
"completed_approver"
],
"restricted_roles": [
"pending_approver",
"external"
]
},
"permissions": {
"can_recall": true,
"can_resubmit": false,
"can_comment": true
}
}

View File

@@ -0,0 +1,13 @@
{
"viewer": [
"view"
],
"approver": [
"view",
"approve"
],
"auditor": [
"view",
"restore"
]
}

View File

@@ -0,0 +1,134 @@
{
"id": 52011,
"approval_no": "APP-202511120045",
"transaction": {
"id": 93010,
"transaction_no": "OUT-20251112-0003"
},
"status": {
"id": 6,
"name": "회수",
"color": "#8B5CF6",
"is_blocking_next": true,
"is_terminal": false
},
"current_step": null,
"requester": {
"id": 88,
"employee_id": "E20250088",
"employee_no": "E20250088",
"name": "오요청"
},
"requested_at": "2025-09-20T01:10:00Z",
"decided_at": "2025-09-20T01:22:30Z",
"note": "출고 회수 테스트",
"is_active": true,
"is_deleted": false,
"steps": [
{
"id": 73011,
"step_order": 1,
"status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"approver": {
"id": 110,
"employee_id": "E20250110",
"employee_no": "E20250110",
"name": "문검토"
},
"assigned_at": "2025-09-20T01:11:00Z",
"decided_at": null,
"note": null
},
{
"id": 73012,
"step_order": 2,
"status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"approver": {
"id": 210,
"employee_id": "E20250210",
"employee_no": "E20250210",
"name": "강팀장"
},
"assigned_at": "2025-09-20T01:11:00Z",
"decided_at": null,
"note": null
}
],
"histories": [
{
"id": 94011,
"action": {
"id": 1,
"name": "상신"
},
"from_status": null,
"to_status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"approver": {
"id": 88,
"employee_id": "E20250088",
"employee_no": "E20250088",
"name": "오요청"
},
"action_at": "2025-09-20T01:10:00Z",
"note": null
},
{
"id": 94012,
"action": {
"id": 5,
"name": "회수"
},
"from_status": {
"id": 2,
"name": "진행중",
"is_blocking_next": true,
"is_terminal": false
},
"to_status": {
"id": 6,
"name": "회수",
"is_blocking_next": true,
"is_terminal": false
},
"approver": {
"id": 88,
"employee_id": "E20250088",
"employee_no": "E20250088",
"name": "오요청"
},
"action_at": "2025-09-20T01:22:30Z",
"note": "승인 지연으로 회수"
}
],
"created_at": "2025-09-20T01:10:00Z",
"updated_at": "2025-09-20T01:22:30Z",
"visibility": {
"allowed_roles": [
"requester"
],
"restricted_roles": [
"pending_approver",
"external"
]
},
"permissions": {
"can_recall": false,
"can_resubmit": true,
"can_comment": true
}
}

View File

@@ -0,0 +1,153 @@
{
"id": 53021,
"approval_no": "APP-202511150089",
"transaction": {
"id": 94090,
"transaction_no": "RENT-20251115-0002"
},
"status": {
"id": 4,
"name": "반려",
"color": "#EF4444",
"is_blocking_next": true,
"is_terminal": true
},
"current_step": {
"id": 74021,
"step_order": 1,
"status": {
"id": 4,
"name": "반려",
"is_blocking_next": true,
"is_terminal": true
},
"approver": {
"id": 120,
"employee_id": "E20250120",
"employee_no": "E20250120",
"name": "신품질"
},
"assigned_at": "2025-09-21T00:05:00Z",
"decided_at": "2025-09-21T00:16:40Z",
"note": "입력 수량 오류"
},
"requester": {
"id": 91,
"employee_id": "E20250091",
"employee_no": "E20250091",
"name": "장요청"
},
"requested_at": "2025-09-21T00:02:00Z",
"decided_at": "2025-09-21T00:16:40Z",
"note": "대여 반려 케이스",
"is_active": true,
"is_deleted": false,
"steps": [
{
"id": 74021,
"step_order": 1,
"status": {
"id": 4,
"name": "반려",
"is_blocking_next": true,
"is_terminal": true
},
"approver": {
"id": 120,
"employee_id": "E20250120",
"employee_no": "E20250120",
"name": "신품질"
},
"assigned_at": "2025-09-21T00:05:00Z",
"decided_at": "2025-09-21T00:16:40Z",
"note": "입력 수량 오류"
},
{
"id": 74022,
"step_order": 2,
"status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"approver": {
"id": 221,
"employee_id": "E20250221",
"employee_no": "E20250221",
"name": "노부장"
},
"assigned_at": "2025-09-21T00:05:00Z",
"decided_at": null,
"note": null
}
],
"histories": [
{
"id": 95021,
"action": {
"id": 1,
"name": "상신"
},
"from_status": null,
"to_status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"approver": {
"id": 91,
"employee_id": "E20250091",
"employee_no": "E20250091",
"name": "장요청"
},
"action_at": "2025-09-21T00:02:00Z",
"note": null
},
{
"id": 95022,
"action": {
"id": 3,
"name": "반려"
},
"from_status": {
"id": 2,
"name": "진행중",
"is_blocking_next": true,
"is_terminal": false
},
"to_status": {
"id": 4,
"name": "반려",
"is_blocking_next": true,
"is_terminal": true
},
"approver": {
"id": 120,
"employee_id": "E20250120",
"employee_no": "E20250120",
"name": "신품질"
},
"action_at": "2025-09-21T00:16:40Z",
"note": "수량 2배 입력"
}
],
"created_at": "2025-09-21T00:02:00Z",
"updated_at": "2025-09-21T00:16:40Z",
"visibility": {
"allowed_roles": [
"requester",
"completed_approver"
],
"restricted_roles": [
"pending_approver",
"external"
]
},
"permissions": {
"can_recall": false,
"can_resubmit": true,
"can_comment": false
}
}

View File

@@ -0,0 +1,47 @@
{
"id": 901,
"template_code": "IN-STD",
"template_name": "입고 표준 결재",
"description": "팀장 → 센터장 → 본부장 순 결재",
"note": "입고 공통 사용",
"is_active": true,
"created_by": {
"id": 77,
"employee_no": "EMP077",
"name": "김상신"
},
"created_at": "2025-09-15T00:10:00Z",
"updated_at": "2025-09-18T05:20:00Z",
"steps": [
{
"id": 9101,
"step_order": 1,
"approver": {
"id": 101,
"employee_no": "EMP101",
"name": "이검토"
},
"note": "입고 검수"
},
{
"id": 9102,
"step_order": 2,
"approver": {
"id": 105,
"employee_no": "EMP105",
"name": "정차장"
},
"note": "센터장 승인"
},
{
"id": 9103,
"step_order": 3,
"approver": {
"id": 201,
"employee_no": "EMP201",
"name": "한임원"
},
"note": "최종 승인"
}
]
}

View File

@@ -0,0 +1,15 @@
import 'dart:convert';
import 'dart:io';
/// test/fixtures 디렉터리의 JSON 파일을 읽어 Map으로 반환한다.
Map<String, dynamic> loadJsonFixture(String relativePath) {
final file = File('test/fixtures/$relativePath');
final contents = file.readAsStringSync();
return json.decode(contents) as Map<String, dynamic>;
}
/// test/fixtures 디렉터리의 텍스트 파일을 그대로 읽어온다.
String readFixture(String relativePath) {
final file = File('test/fixtures/$relativePath');
return file.readAsStringSync();
}

View File

@@ -6,6 +6,8 @@ import 'package:superport_v2/features/inventory/lookups/domain/repositories/inve
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';
import 'package:superport_v2/features/masters/product/domain/entities/product.dart';
import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart';
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
@@ -26,9 +28,13 @@ const int _statusRentalFinishedId = 33;
StockTransactionListFilter? lastTransactionListFilter;
class InventoryTestStubConfig {
const InventoryTestStubConfig({this.submitFailure});
const InventoryTestStubConfig({
this.submitFailure,
this.registerProductRepository = false,
});
final ApiException? submitFailure;
final bool registerProductRepository;
}
InventoryTestStubConfig _stubConfig = const InventoryTestStubConfig();
@@ -55,6 +61,10 @@ void registerInventoryTestStubs([
LookupItem(id: _statusRentalReturnWaitId, name: '반납대기'),
LookupItem(id: _statusRentalFinishedId, name: '완료'),
],
approvalStatuses: [
LookupItem(id: 401, name: '승인대기', isDefault: true),
LookupItem(id: 402, name: '승인완료'),
],
);
final transactions = _buildTransactions();
@@ -73,7 +83,6 @@ void registerInventoryTestStubs([
Warehouse(id: 3, warehouseCode: 'WH-003', warehouseName: '대전 물류'),
];
final warehouseRepository = _StubWarehouseRepository(warehouses: warehouses);
final getIt = GetIt.I;
if (getIt.isRegistered<InventoryLookupRepository>()) {
getIt.unregister<InventoryLookupRepository>();
@@ -95,17 +104,52 @@ void registerInventoryTestStubs([
getIt.registerSingleton<TransactionLineRepository>(lineRepository);
getIt.registerSingleton<TransactionCustomerRepository>(customerRepository);
getIt.registerSingleton<WarehouseRepository>(warehouseRepository);
if (config.registerProductRepository) {
final products = [
Product(
id: 501,
productCode: 'XR-5000',
productName: 'XR-5000',
vendor: ProductVendor(
id: 11,
vendorCode: 'VN-11',
vendorName: 'X-Ray Co.',
),
uom: ProductUom(id: 21, uomName: 'EA'),
),
Product(
id: 502,
productCode: 'Eco-200',
productName: 'Eco-200',
vendor: ProductVendor(
id: 12,
vendorCode: 'VN-12',
vendorName: 'Eco Supplies',
),
uom: ProductUom(id: 22, uomName: 'EA'),
),
];
if (getIt.isRegistered<ProductRepository>()) {
getIt.unregister<ProductRepository>();
}
getIt.registerSingleton<ProductRepository>(
_StubProductRepository(products: products),
);
}
}
class _StubInventoryLookupRepository implements InventoryLookupRepository {
_StubInventoryLookupRepository({
required List<LookupItem> transactionTypes,
required List<LookupItem> statuses,
required List<LookupItem> approvalStatuses,
}) : _transactionTypes = transactionTypes,
_statuses = statuses;
_statuses = statuses,
_approvalStatuses = approvalStatuses;
final List<LookupItem> _transactionTypes;
final List<LookupItem> _statuses;
final List<LookupItem> _approvalStatuses;
@override
Future<List<LookupItem>> fetchTransactionTypes({
@@ -125,7 +169,7 @@ class _StubInventoryLookupRepository implements InventoryLookupRepository {
Future<List<LookupItem>> fetchApprovalStatuses({
bool activeOnly = true,
}) async {
return const [];
return _approvalStatuses;
}
@override
@@ -136,6 +180,60 @@ class _StubInventoryLookupRepository implements InventoryLookupRepository {
}
}
class _StubProductRepository implements ProductRepository {
_StubProductRepository({required List<Product> products})
: _products = products;
final List<Product> _products;
@override
Future<PaginatedResult<Product>> list({
int page = 1,
int pageSize = 20,
String? query,
int? vendorId,
int? uomId,
bool? isActive,
}) async {
Iterable<Product> filtered = _products;
if (query != null && query.trim().isNotEmpty) {
final normalized = query.trim().toLowerCase();
filtered = filtered.where(
(product) =>
product.productCode.toLowerCase().contains(normalized) ||
product.productName.toLowerCase().contains(normalized),
);
}
final items = filtered.toList(growable: false);
return PaginatedResult<Product>(
items: items,
page: page,
pageSize: pageSize,
total: items.length,
);
}
@override
Future<Product> create(ProductInput input) {
throw UnimplementedError();
}
@override
Future<void> delete(int id) {
throw UnimplementedError();
}
@override
Future<Product> restore(int id) {
throw UnimplementedError();
}
@override
Future<Product> update(int id, ProductInput input) {
throw UnimplementedError();
}
}
class _StubStockTransactionRepository implements StockTransactionRepository {
_StubStockTransactionRepository({
required List<StockTransaction> transactions,

View File

@@ -273,7 +273,9 @@ void _registerAuthService(
_FakeTokenStorage storage,
) {
final service = AuthService(repository: repository, tokenStorage: storage);
when(() => repository.login(any())).thenAnswer((_) async => _buildSampleSession());
when(
() => repository.login(any()),
).thenAnswer((_) async => _buildSampleSession());
when(() => repository.refresh(any())).thenThrow(UnimplementedError());
GetIt.I.registerSingleton<AuthService>(service);
}

View File

@@ -78,17 +78,19 @@ void main() {
final session = _buildSession();
final authService = _createAuthService(session);
final captured = <UserProfileUpdateInput>[];
final repository = _StubUserRepository(onUpdateMe: (input) async {
captured.add(input);
return UserAccount(
id: session.user.id,
employeeNo: session.user.employeeNo ?? '',
employeeName: session.user.name,
email: input.email,
mobileNo: input.phone,
group: UserGroup(id: 1, groupName: '물류팀'),
);
});
final repository = _StubUserRepository(
onUpdateMe: (input) async {
captured.add(input);
return UserAccount(
id: session.user.id,
employeeNo: session.user.employeeNo ?? '',
employeeName: session.user.name,
email: input.email,
mobileNo: input.phone,
group: UserGroup(id: 1, groupName: '물류팀'),
);
},
);
GetIt.I.registerSingleton<AuthService>(authService);
GetIt.I.registerSingleton<UserRepository>(repository);
@@ -134,17 +136,19 @@ void main() {
final session = _buildSession();
final authService = _createAuthService(session);
UserProfileUpdateInput? passwordInput;
final repository = _StubUserRepository(onUpdateMe: (input) async {
passwordInput = input;
return UserAccount(
id: session.user.id,
employeeNo: session.user.employeeNo ?? '',
employeeName: session.user.name,
email: input.email ?? session.user.email,
mobileNo: input.phone ?? session.user.phone,
group: UserGroup(id: 1, groupName: '물류팀'),
);
});
final repository = _StubUserRepository(
onUpdateMe: (input) async {
passwordInput = input;
return UserAccount(
id: session.user.id,
employeeNo: session.user.employeeNo ?? '',
employeeName: session.user.name,
email: input.email ?? session.user.email,
mobileNo: input.phone ?? session.user.phone,
group: UserGroup(id: 1, groupName: '물류팀'),
);
},
);
GetIt.I.registerSingleton<AuthService>(authService);
GetIt.I.registerSingleton<UserRepository>(repository);