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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,9 +92,7 @@ void main() {
|
||||
requestOptions: requestOptions,
|
||||
statusCode: 401,
|
||||
data: {
|
||||
'error': {
|
||||
'message': 'invalid credentials',
|
||||
},
|
||||
'error': {'message': 'invalid credentials'},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
138
test/features/approvals/data/dtos/approval_request_dto_test.dart
Normal file
138
test/features/approvals/data/dtos/approval_request_dto_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 [],
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/network/failure.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_flow.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/recall_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/resubmit_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/entities/approval_history_record.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/history/presentation/controllers/approval_history_controller.dart';
|
||||
@@ -10,16 +15,27 @@ import 'package:superport_v2/features/approvals/history/presentation/controllers
|
||||
class _MockApprovalHistoryRepository extends Mock
|
||||
implements ApprovalHistoryRepository {}
|
||||
|
||||
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
|
||||
|
||||
class _MockRecallApprovalUseCase extends Mock
|
||||
implements RecallApprovalUseCase {}
|
||||
|
||||
class _MockResubmitApprovalUseCase extends Mock
|
||||
implements ResubmitApprovalUseCase {}
|
||||
|
||||
void main() {
|
||||
late ApprovalHistoryController controller;
|
||||
late _MockApprovalHistoryRepository repository;
|
||||
late _MockApprovalRepository approvalRepository;
|
||||
late _MockRecallApprovalUseCase recallUseCase;
|
||||
late _MockResubmitApprovalUseCase resubmitUseCase;
|
||||
|
||||
final record = ApprovalHistoryRecord(
|
||||
id: 1,
|
||||
approvalId: 10,
|
||||
approvalNo: 'APP-2024-0001',
|
||||
stepOrder: 1,
|
||||
action: ApprovalAction(id: 11, name: 'approve'),
|
||||
action: ApprovalAction(id: 11, name: 'approve', code: 'approve'),
|
||||
fromStatus: ApprovalStatus(id: 1, name: '대기', color: null),
|
||||
toStatus: ApprovalStatus(id: 2, name: '승인', color: null),
|
||||
approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'),
|
||||
@@ -27,6 +43,16 @@ void main() {
|
||||
note: '승인 완료',
|
||||
);
|
||||
|
||||
final auditEntry = ApprovalHistory(
|
||||
id: 2,
|
||||
action: ApprovalAction(id: 12, name: 'recall', code: 'recall'),
|
||||
fromStatus: ApprovalStatus(id: 2, name: '승인'),
|
||||
toStatus: ApprovalStatus(id: 3, name: '회수'),
|
||||
approver: ApprovalApprover(id: 22, employeeNo: 'E002', name: '박회수'),
|
||||
actionAt: DateTime(2024, 4, 2, 10),
|
||||
note: '요청자 회수',
|
||||
);
|
||||
|
||||
PaginatedResult<ApprovalHistoryRecord> createResult(
|
||||
List<ApprovalHistoryRecord> items,
|
||||
) {
|
||||
@@ -38,9 +64,49 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
PaginatedResult<ApprovalHistory> createAuditResult(
|
||||
List<ApprovalHistory> items,
|
||||
) {
|
||||
return PaginatedResult<ApprovalHistory>(
|
||||
items: items,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: items.length,
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalFlow createFlow(int approvalId) {
|
||||
final status = ApprovalStatus(id: 5, name: '대기');
|
||||
final requester = ApprovalRequester(
|
||||
id: 88,
|
||||
employeeNo: 'EMP088',
|
||||
name: '상신자',
|
||||
);
|
||||
final approval = Approval(
|
||||
id: approvalId,
|
||||
approvalNo: 'APP-$approvalId',
|
||||
transactionId: approvalId * 1000,
|
||||
transactionNo: 'TRX-$approvalId',
|
||||
status: status,
|
||||
requester: requester,
|
||||
requestedAt: DateTime(2024, 4, 1),
|
||||
transactionUpdatedAt: DateTime(2024, 4, 1, 12),
|
||||
steps: const [],
|
||||
);
|
||||
return ApprovalFlow(approval: approval);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
repository = _MockApprovalHistoryRepository();
|
||||
controller = ApprovalHistoryController(repository: repository);
|
||||
approvalRepository = _MockApprovalRepository();
|
||||
recallUseCase = _MockRecallApprovalUseCase();
|
||||
resubmitUseCase = _MockResubmitApprovalUseCase();
|
||||
controller = ApprovalHistoryController(
|
||||
repository: repository,
|
||||
approvalRepository: approvalRepository,
|
||||
recallUseCase: recallUseCase,
|
||||
resubmitUseCase: resubmitUseCase,
|
||||
);
|
||||
});
|
||||
|
||||
test('fetch 성공 시 결과를 갱신한다', () async {
|
||||
@@ -131,4 +197,186 @@ void main() {
|
||||
expect(controller.from, isNull);
|
||||
expect(controller.to, isNull);
|
||||
});
|
||||
|
||||
test('fetchAuditLogs는 ApprovalRepository를 사용한다', () async {
|
||||
when(
|
||||
() => approvalRepository.listHistory(
|
||||
approvalId: any(named: 'approvalId'),
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
actorId: any(named: 'actorId'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
),
|
||||
).thenAnswer((_) async => createAuditResult([auditEntry]));
|
||||
|
||||
await controller.fetchAuditLogs(approvalId: 10);
|
||||
|
||||
expect(controller.auditResult?.items, isNotEmpty);
|
||||
expect(controller.selectedApprovalId, 10);
|
||||
expect(controller.auditPageSize, 10);
|
||||
});
|
||||
|
||||
test('updateActiveTab이 탭 상태를 변경한다', () {
|
||||
expect(controller.activeTab, ApprovalHistoryTab.flow);
|
||||
controller.updateActiveTab(ApprovalHistoryTab.audit);
|
||||
expect(controller.activeTab, ApprovalHistoryTab.audit);
|
||||
});
|
||||
|
||||
test('clearAuditSelection은 감사 로그 상태를 초기화한다', () async {
|
||||
when(
|
||||
() => approvalRepository.listHistory(
|
||||
approvalId: any(named: 'approvalId'),
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
actorId: any(named: 'actorId'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
),
|
||||
).thenAnswer((_) async => createAuditResult([auditEntry]));
|
||||
|
||||
await controller.fetchAuditLogs(approvalId: 10);
|
||||
controller.clearAuditSelection();
|
||||
|
||||
expect(controller.selectedApprovalId, isNull);
|
||||
expect(controller.auditResult, isNull);
|
||||
});
|
||||
|
||||
test('recallApproval은 유즈케이스를 호출하고 목록을 새로고침한다', () async {
|
||||
final recallInput = ApprovalRecallInput(approvalId: 10, actorId: 77);
|
||||
when(
|
||||
() => recallUseCase.call(recallInput),
|
||||
).thenAnswer((_) async => createFlow(10));
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
action: any(named: 'action'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
).thenAnswer((_) async => createResult([record]));
|
||||
when(
|
||||
() => approvalRepository.listHistory(
|
||||
approvalId: any(named: 'approvalId'),
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
actorId: any(named: 'actorId'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
),
|
||||
).thenAnswer((_) async => createAuditResult([auditEntry]));
|
||||
|
||||
await controller.fetchAuditLogs(approvalId: 10);
|
||||
final flow = await controller.recallApproval(recallInput);
|
||||
|
||||
expect(flow, isNotNull);
|
||||
expect(controller.isPerformingAction, isFalse);
|
||||
verify(() => recallUseCase.call(recallInput)).called(1);
|
||||
verify(
|
||||
() => repository.list(
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
query: null,
|
||||
action: null,
|
||||
from: null,
|
||||
to: null,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
verify(
|
||||
() => approvalRepository.listHistory(
|
||||
approvalId: 10,
|
||||
page: 1,
|
||||
pageSize: any(named: 'pageSize'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
actorId: null,
|
||||
approvalActionId: null,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
});
|
||||
|
||||
test('resubmitApproval은 유즈케이스를 호출한다', () async {
|
||||
final submission = ApprovalSubmissionInput(
|
||||
statusId: 3,
|
||||
requesterId: 77,
|
||||
steps: const [],
|
||||
);
|
||||
final resubmitInput = ApprovalResubmissionInput(
|
||||
approvalId: 11,
|
||||
actorId: 77,
|
||||
submission: submission,
|
||||
);
|
||||
when(
|
||||
() => resubmitUseCase.call(resubmitInput),
|
||||
).thenAnswer((_) async => createFlow(11));
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
action: any(named: 'action'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
).thenAnswer((_) async => createResult([record]));
|
||||
when(
|
||||
() => approvalRepository.listHistory(
|
||||
approvalId: any(named: 'approvalId'),
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
actorId: any(named: 'actorId'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
),
|
||||
).thenAnswer((_) async => createAuditResult([auditEntry]));
|
||||
|
||||
await controller.fetchAuditLogs(approvalId: 11);
|
||||
final flow = await controller.resubmitApproval(resubmitInput);
|
||||
|
||||
expect(flow, isNotNull);
|
||||
expect(controller.isPerformingAction, isFalse);
|
||||
verify(() => resubmitUseCase.call(resubmitInput)).called(1);
|
||||
});
|
||||
|
||||
test('loadApprovalFlow는 403 응답 시 선택을 금지한다', () async {
|
||||
when(
|
||||
() => approvalRepository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenThrow(const Failure(message: '접근이 거부되었습니다.', statusCode: 403));
|
||||
|
||||
await controller.loadApprovalFlow(77);
|
||||
|
||||
expect(controller.isSelectionForbidden, isTrue);
|
||||
expect(controller.selectedFlow, isNull);
|
||||
expect(controller.errorMessage, contains('접근이 거부'));
|
||||
});
|
||||
|
||||
test('fetchAuditLogs는 403 응답 시 선택을 차단한다', () async {
|
||||
when(
|
||||
() => approvalRepository.listHistory(
|
||||
approvalId: any(named: 'approvalId'),
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
actorId: any(named: 'actorId'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
),
|
||||
).thenThrow(const Failure(message: '접근 불가', statusCode: 403));
|
||||
|
||||
await controller.fetchAuditLogs(approvalId: 12);
|
||||
|
||||
expect(controller.isSelectionForbidden, isTrue);
|
||||
expect(controller.auditResult, isNull);
|
||||
expect(controller.errorMessage, contains('접근 불가'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,13 +7,32 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_flow.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/entities/approval_history_record.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/history/presentation/pages/approval_history_page.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/recall_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/resubmit_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/auth/application/auth_service.dart';
|
||||
import 'package:superport_v2/features/auth/domain/entities/auth_permission.dart';
|
||||
import 'package:superport_v2/features/auth/domain/entities/auth_session.dart';
|
||||
import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_table.dart';
|
||||
|
||||
class _MockApprovalHistoryRepository extends Mock
|
||||
implements ApprovalHistoryRepository {}
|
||||
|
||||
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
|
||||
|
||||
class _MockRecallApprovalUseCase extends Mock
|
||||
implements RecallApprovalUseCase {}
|
||||
|
||||
class _MockResubmitApprovalUseCase extends Mock
|
||||
implements ResubmitApprovalUseCase {}
|
||||
|
||||
class _MockAuthService extends Mock implements AuthService {}
|
||||
|
||||
Widget _buildApp(Widget child) {
|
||||
return MaterialApp(
|
||||
home: ShadTheme(
|
||||
@@ -29,14 +48,33 @@ Widget _buildApp(Widget child) {
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late _MockApprovalHistoryRepository repository;
|
||||
late _MockApprovalHistoryRepository historyRepository;
|
||||
late _MockApprovalRepository approvalRepository;
|
||||
late _MockRecallApprovalUseCase recallUseCase;
|
||||
late _MockResubmitApprovalUseCase resubmitUseCase;
|
||||
late _MockAuthService authService;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(ApprovalRecallInput(approvalId: 0, actorId: 0));
|
||||
registerFallbackValue(
|
||||
ApprovalResubmissionInput(
|
||||
approvalId: 0,
|
||||
actorId: 0,
|
||||
submission: ApprovalSubmissionInput(
|
||||
statusId: 0,
|
||||
requesterId: 0,
|
||||
steps: const [],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
final record = ApprovalHistoryRecord(
|
||||
id: 1,
|
||||
approvalId: 10,
|
||||
approvalNo: 'APP-2024-0001',
|
||||
stepOrder: 1,
|
||||
action: ApprovalAction(id: 11, name: 'approve'),
|
||||
action: ApprovalAction(id: 11, name: 'approve', code: 'approve'),
|
||||
fromStatus: ApprovalStatus(id: 1, name: '대기', color: null),
|
||||
toStatus: ApprovalStatus(id: 2, name: '승인', color: null),
|
||||
approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'),
|
||||
@@ -44,6 +82,160 @@ void main() {
|
||||
note: '승인 완료',
|
||||
);
|
||||
|
||||
final secondRecord = ApprovalHistoryRecord(
|
||||
id: 2,
|
||||
approvalId: 11,
|
||||
approvalNo: 'APP-2024-0002',
|
||||
stepOrder: 2,
|
||||
action: ApprovalAction(id: 12, name: 'submit', code: 'submit'),
|
||||
fromStatus: ApprovalStatus(id: 1, name: '대기', color: null),
|
||||
toStatus: ApprovalStatus(id: 3, name: '진행중', color: null),
|
||||
approver: ApprovalApprover(id: 31, employeeNo: 'E031', name: '초기승인'),
|
||||
actionAt: DateTime(2024, 4, 2, 9, 30),
|
||||
note: '상신 완료',
|
||||
);
|
||||
|
||||
ApprovalFlow stubFlow() {
|
||||
final approval = Approval(
|
||||
transactionId: 1,
|
||||
approvalNo: 'APP-2024-0001',
|
||||
transactionNo: 'TRX-001',
|
||||
status: ApprovalStatus(id: 2, name: '승인'),
|
||||
requester: ApprovalRequester(id: 99, employeeNo: 'E099', name: '테스터'),
|
||||
requestedAt: DateTime(2024, 4, 1),
|
||||
steps: const [],
|
||||
histories: const [],
|
||||
transactionUpdatedAt: DateTime(2024, 4, 1, 12),
|
||||
);
|
||||
return ApprovalFlow(approval: approval);
|
||||
}
|
||||
|
||||
ApprovalFlow recallableFlow() {
|
||||
final status = ApprovalStatus(id: 2, name: '진행중', isTerminal: false);
|
||||
final approver = ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인');
|
||||
final steps = [
|
||||
ApprovalStep(
|
||||
id: 100,
|
||||
stepOrder: 1,
|
||||
approver: approver,
|
||||
status: status,
|
||||
assignedAt: DateTime(2024, 4, 1, 9),
|
||||
decidedAt: null,
|
||||
),
|
||||
ApprovalStep(
|
||||
id: 101,
|
||||
stepOrder: 2,
|
||||
approver: approver,
|
||||
status: status,
|
||||
assignedAt: DateTime(2024, 4, 1, 10),
|
||||
decidedAt: DateTime(2024, 4, 1, 10, 30),
|
||||
),
|
||||
];
|
||||
final approval = Approval(
|
||||
id: 10,
|
||||
approvalNo: 'APP-2024-0001',
|
||||
transactionId: 10,
|
||||
transactionNo: 'TRX-001',
|
||||
status: status,
|
||||
requester: ApprovalRequester(id: 99, employeeNo: 'E099', name: '테스터'),
|
||||
requestedAt: DateTime(2024, 4, 1),
|
||||
steps: steps,
|
||||
histories: const [],
|
||||
updatedAt: DateTime(2024, 4, 1, 12),
|
||||
transactionUpdatedAt: DateTime(2024, 4, 1, 11, 50),
|
||||
);
|
||||
return ApprovalFlow(approval: approval);
|
||||
}
|
||||
|
||||
// ignore: unused_element
|
||||
ApprovalFlow resubmittableFlow() {
|
||||
final pendingStatus = ApprovalStatus(id: 2, name: '대기', isTerminal: false);
|
||||
final rejectedStatus = ApprovalStatus(id: 5, name: '반려', isTerminal: true);
|
||||
final firstApprover = ApprovalApprover(
|
||||
id: 31,
|
||||
employeeNo: 'E002',
|
||||
name: '1차 승인자',
|
||||
);
|
||||
final finalApprover = ApprovalApprover(
|
||||
id: 32,
|
||||
employeeNo: 'E003',
|
||||
name: '최종 승인자',
|
||||
);
|
||||
final steps = [
|
||||
ApprovalStep(
|
||||
id: 120,
|
||||
stepOrder: 1,
|
||||
approver: firstApprover,
|
||||
status: pendingStatus,
|
||||
assignedAt: DateTime(2024, 3, 30, 9),
|
||||
decidedAt: DateTime(2024, 3, 30, 10),
|
||||
),
|
||||
ApprovalStep(
|
||||
id: 121,
|
||||
stepOrder: 2,
|
||||
approver: finalApprover,
|
||||
status: rejectedStatus,
|
||||
assignedAt: DateTime(2024, 3, 30, 11),
|
||||
decidedAt: DateTime(2024, 3, 30, 12),
|
||||
note: '보완 필요',
|
||||
),
|
||||
];
|
||||
final approval = Approval(
|
||||
id: 10,
|
||||
approvalNo: 'APP-2024-0001',
|
||||
transactionId: 11,
|
||||
transactionNo: 'TRX-001',
|
||||
status: rejectedStatus,
|
||||
requester: ApprovalRequester(id: 99, employeeNo: 'E099', name: '테스터'),
|
||||
requestedAt: DateTime(2024, 3, 30),
|
||||
decidedAt: DateTime(2024, 3, 30, 12, 30),
|
||||
note: '반려 사유 공유',
|
||||
steps: steps,
|
||||
histories: const [],
|
||||
updatedAt: DateTime(2024, 3, 30, 12, 30),
|
||||
transactionUpdatedAt: DateTime(2024, 3, 30, 12),
|
||||
);
|
||||
return ApprovalFlow(approval: approval);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
historyRepository = _MockApprovalHistoryRepository();
|
||||
approvalRepository = _MockApprovalRepository();
|
||||
recallUseCase = _MockRecallApprovalUseCase();
|
||||
resubmitUseCase = _MockResubmitApprovalUseCase();
|
||||
authService = _MockAuthService();
|
||||
|
||||
final sl = GetIt.I;
|
||||
sl.registerLazySingleton<ApprovalHistoryRepository>(
|
||||
() => historyRepository,
|
||||
);
|
||||
sl.registerLazySingleton<ApprovalRepository>(() => approvalRepository);
|
||||
sl.registerLazySingleton<RecallApprovalUseCase>(() => recallUseCase);
|
||||
sl.registerLazySingleton<ResubmitApprovalUseCase>(() => resubmitUseCase);
|
||||
sl.registerLazySingleton<AuthService>(() => authService);
|
||||
|
||||
when(
|
||||
() => approvalRepository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenAnswer((_) async => stubFlow().approval);
|
||||
|
||||
when(() => authService.session).thenReturn(
|
||||
AuthSession(
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
user: const AuthenticatedUser(id: 99, name: '테스터', employeeNo: 'E099'),
|
||||
permissions: const <AuthPermission>[],
|
||||
),
|
||||
);
|
||||
|
||||
when(() => recallUseCase.call(any())).thenAnswer((_) async => stubFlow());
|
||||
when(() => resubmitUseCase.call(any())).thenAnswer((_) async => stubFlow());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await GetIt.I.reset();
|
||||
dotenv.clean();
|
||||
@@ -61,11 +253,53 @@ void main() {
|
||||
|
||||
testWidgets('이력 목록을 렌더링하고 검색 필터를 적용한다', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
|
||||
repository = _MockApprovalHistoryRepository();
|
||||
GetIt.I.registerLazySingleton<ApprovalHistoryRepository>(() => repository);
|
||||
|
||||
when(
|
||||
() => repository.list(
|
||||
() => historyRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
action: any(named: 'action'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => PaginatedResult<ApprovalHistoryRecord>(
|
||||
items: [record, secondRecord],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 2,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ApprovalHistoryPage()));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('APP-2024-0001'), findsOneWidget);
|
||||
expect(find.text('승인 완료'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(ShadInput).first, 'APP-2024');
|
||||
await tester.tap(find.text('검색 적용'));
|
||||
await tester.pump();
|
||||
|
||||
verify(
|
||||
() => historyRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: 20,
|
||||
query: 'APP-2024',
|
||||
action: null,
|
||||
from: null,
|
||||
to: null,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
});
|
||||
|
||||
testWidgets('회수 시 상세 재조회 실패 안내를 노출한다', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
|
||||
|
||||
when(
|
||||
() => historyRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
@@ -82,26 +316,45 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final recallable = recallableFlow();
|
||||
var fetchCount = 0;
|
||||
when(
|
||||
() => approvalRepository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenAnswer((_) async {
|
||||
if (fetchCount == 0) {
|
||||
fetchCount++;
|
||||
return recallable.approval;
|
||||
}
|
||||
fetchCount++;
|
||||
throw Exception('refresh failed');
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ApprovalHistoryPage()));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('APP-2024-0001'), findsOneWidget);
|
||||
expect(find.text('승인 완료'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(ShadInput).first, 'APP-2024');
|
||||
await tester.tap(find.text('검색 적용'));
|
||||
final table = tester.widget<SuperportTable>(find.byType(SuperportTable));
|
||||
table.onRowTap?.call(0);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: 20,
|
||||
query: 'APP-2024',
|
||||
action: null,
|
||||
from: null,
|
||||
to: null,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
final recallButton = find.widgetWithText(ShadButton, '회수').first;
|
||||
await tester.ensureVisible(recallButton);
|
||||
await tester.tap(recallButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('결재 회수'), findsOneWidget);
|
||||
final confirmButton = find.widgetWithText(ShadButton, '회수').last;
|
||||
await tester.ensureVisible(confirmButton);
|
||||
await tester.tap(confirmButton);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(fetchCount, equals(2));
|
||||
expect(find.text('결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_flow.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/recall_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/resubmit_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/history/presentation/controllers/approval_history_controller.dart';
|
||||
|
||||
class _MockApprovalHistoryRepository extends Mock
|
||||
implements ApprovalHistoryRepository {}
|
||||
|
||||
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
|
||||
|
||||
class _MockRecallApprovalUseCase extends Mock
|
||||
implements RecallApprovalUseCase {}
|
||||
|
||||
class _MockResubmitApprovalUseCase extends Mock
|
||||
implements ResubmitApprovalUseCase {}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late ApprovalHistoryRepository historyRepository;
|
||||
late ApprovalRepository approvalRepository;
|
||||
late RecallApprovalUseCase recallUseCase;
|
||||
late ResubmitApprovalUseCase resubmitUseCase;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(ApprovalRecallInput(approvalId: 0, actorId: 0));
|
||||
registerFallbackValue(
|
||||
ApprovalResubmissionInput(
|
||||
approvalId: 0,
|
||||
actorId: 0,
|
||||
submission: ApprovalSubmissionInput(statusId: 0, requesterId: 0),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
historyRepository = _MockApprovalHistoryRepository();
|
||||
approvalRepository = _MockApprovalRepository();
|
||||
recallUseCase = _MockRecallApprovalUseCase();
|
||||
resubmitUseCase = _MockResubmitApprovalUseCase();
|
||||
});
|
||||
|
||||
ApprovalFlow buildRecallableFlow() {
|
||||
final status = ApprovalStatus(id: 1, name: '진행중');
|
||||
final steps = [
|
||||
ApprovalStep(
|
||||
id: 1,
|
||||
stepOrder: 1,
|
||||
approver: ApprovalApprover(id: 101, employeeNo: 'E101', name: '1차'),
|
||||
status: status,
|
||||
assignedAt: DateTime(2024, 4, 1, 9),
|
||||
),
|
||||
ApprovalStep(
|
||||
id: 2,
|
||||
stepOrder: 2,
|
||||
approver: ApprovalApprover(id: 102, employeeNo: 'E102', name: '2차'),
|
||||
status: status,
|
||||
assignedAt: DateTime(2024, 4, 1, 10),
|
||||
decidedAt: DateTime(2024, 4, 1, 10, 30),
|
||||
),
|
||||
];
|
||||
final approval = Approval(
|
||||
id: 500,
|
||||
approvalNo: 'APP-500',
|
||||
transactionId: 5000,
|
||||
transactionNo: 'TRX-500',
|
||||
status: status,
|
||||
requester: ApprovalRequester(id: 90, employeeNo: 'E090', name: '상신자'),
|
||||
requestedAt: DateTime(2024, 4, 1),
|
||||
steps: steps,
|
||||
histories: const [],
|
||||
updatedAt: DateTime(2024, 4, 1, 12),
|
||||
currentStep: steps.first,
|
||||
transactionUpdatedAt: DateTime(2024, 4, 1, 11, 30),
|
||||
);
|
||||
return ApprovalFlow(approval: approval);
|
||||
}
|
||||
|
||||
ApprovalFlow buildResubmittableFlow() {
|
||||
final rejectedStatus = ApprovalStatus(id: 9, name: '반려', isTerminal: true);
|
||||
final steps = [
|
||||
ApprovalStep(
|
||||
id: 1,
|
||||
stepOrder: 1,
|
||||
approver: ApprovalApprover(id: 101, employeeNo: 'E101', name: '1차'),
|
||||
status: rejectedStatus,
|
||||
assignedAt: DateTime(2024, 3, 30, 9),
|
||||
decidedAt: DateTime(2024, 3, 30, 9, 30),
|
||||
),
|
||||
ApprovalStep(
|
||||
id: 2,
|
||||
stepOrder: 2,
|
||||
approver: ApprovalApprover(id: 102, employeeNo: 'E102', name: '최종'),
|
||||
status: rejectedStatus,
|
||||
assignedAt: DateTime(2024, 3, 30, 10),
|
||||
decidedAt: DateTime(2024, 3, 30, 10, 30),
|
||||
note: '보완 필요',
|
||||
),
|
||||
];
|
||||
final approval = Approval(
|
||||
id: 600,
|
||||
approvalNo: 'APP-600',
|
||||
transactionId: 6000,
|
||||
transactionNo: 'TRX-600',
|
||||
status: rejectedStatus,
|
||||
requester: ApprovalRequester(id: 90, employeeNo: 'E090', name: '상신자'),
|
||||
requestedAt: DateTime(2024, 3, 30),
|
||||
decidedAt: DateTime(2024, 3, 30, 12),
|
||||
note: '반려 메모',
|
||||
steps: steps,
|
||||
histories: const [],
|
||||
updatedAt: DateTime(2024, 3, 30, 12),
|
||||
currentStep: null,
|
||||
transactionUpdatedAt: DateTime(2024, 3, 30, 11, 45),
|
||||
);
|
||||
return ApprovalFlow(approval: approval);
|
||||
}
|
||||
|
||||
Future<ApprovalHistoryController> buildController(ApprovalFlow flow) async {
|
||||
final controller = ApprovalHistoryController(
|
||||
repository: historyRepository,
|
||||
approvalRepository: approvalRepository,
|
||||
recallUseCase: recallUseCase,
|
||||
resubmitUseCase: resubmitUseCase,
|
||||
);
|
||||
|
||||
when(
|
||||
() => approvalRepository.fetchDetail(
|
||||
flow.id!,
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenAnswer((_) async => flow.approval);
|
||||
when(() => approvalRepository.canProceed(flow.id!)).thenAnswer(
|
||||
(_) async => ApprovalProceedStatus(
|
||||
approvalId: flow.id!,
|
||||
canProceed: flow.currentStep != null,
|
||||
),
|
||||
);
|
||||
|
||||
await controller.loadApprovalFlow(flow.id!);
|
||||
return controller;
|
||||
}
|
||||
|
||||
testWidgets('회수 버튼이 노트를 전달해 유즈케이스를 호출한다', (tester) async {
|
||||
final flow = buildRecallableFlow();
|
||||
final controller = await buildController(flow);
|
||||
|
||||
ApprovalRecallInput? captured;
|
||||
when(() => recallUseCase.call(any())).thenAnswer((invocation) async {
|
||||
captured = invocation.positionalArguments.first as ApprovalRecallInput;
|
||||
return flow;
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
_ApprovalActionHarness(controller: controller, actorId: 99),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('recall-note-field')),
|
||||
' 긴급 회수 ',
|
||||
);
|
||||
await tester.tap(find.text('회수'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(captured, isNotNull);
|
||||
expect(captured?.approvalId, flow.id);
|
||||
expect(captured?.actorId, 99);
|
||||
expect(captured?.note, '긴급 회수');
|
||||
expect(captured?.transactionExpectedUpdatedAt, flow.transactionUpdatedAt);
|
||||
});
|
||||
|
||||
testWidgets('재상신 버튼이 템플릿 단계를 포함해 유즈케이스를 호출한다', (tester) async {
|
||||
final flow = buildResubmittableFlow();
|
||||
final controller = await buildController(flow);
|
||||
|
||||
ApprovalResubmissionInput? captured;
|
||||
when(() => resubmitUseCase.call(any())).thenAnswer((invocation) async {
|
||||
captured =
|
||||
invocation.positionalArguments.first as ApprovalResubmissionInput;
|
||||
return flow;
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
_ApprovalActionHarness(controller: controller, actorId: 99),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('resubmit-note-field')),
|
||||
' 재상신 메모 ',
|
||||
);
|
||||
await tester.tap(find.text('재상신'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(captured, isNotNull);
|
||||
expect(captured?.actorId, 99);
|
||||
expect(captured?.note, '재상신 메모');
|
||||
expect(captured?.submission.steps.length, flow.steps.length);
|
||||
expect(captured?.submission.requesterId, flow.requester.id);
|
||||
expect(captured?.submission.transactionId, flow.transactionId);
|
||||
expect(captured?.transactionExpectedUpdatedAt, flow.transactionUpdatedAt);
|
||||
});
|
||||
}
|
||||
|
||||
class _ApprovalActionHarness extends StatefulWidget {
|
||||
const _ApprovalActionHarness({
|
||||
required this.controller,
|
||||
required this.actorId,
|
||||
});
|
||||
|
||||
final ApprovalHistoryController controller;
|
||||
final int actorId;
|
||||
|
||||
@override
|
||||
State<_ApprovalActionHarness> createState() => _ApprovalActionHarnessState();
|
||||
}
|
||||
|
||||
class _ApprovalActionHarnessState extends State<_ApprovalActionHarness> {
|
||||
final TextEditingController _recallNoteController = TextEditingController();
|
||||
final TextEditingController _resubmitNoteController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: ShadTheme(
|
||||
data: ShadThemeData(
|
||||
colorScheme: const ShadSlateColorScheme.light(),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
child: Scaffold(
|
||||
body: AnimatedBuilder(
|
||||
animation: widget.controller,
|
||||
builder: (context, _) {
|
||||
final flow = widget.controller.selectedFlow;
|
||||
if (flow == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final canRecall = _canRecall(flow);
|
||||
final canResubmit = _canResubmit(flow);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadInput(
|
||||
key: const ValueKey('recall-note-field'),
|
||||
controller: _recallNoteController,
|
||||
placeholder: const Text('회수 사유'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ShadButton(
|
||||
onPressed: canRecall ? () => _handleRecall(flow) : null,
|
||||
child: const Text('회수'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ShadInput(
|
||||
key: const ValueKey('resubmit-note-field'),
|
||||
controller: _resubmitNoteController,
|
||||
placeholder: const Text('재상신 메모'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ShadButton.outline(
|
||||
onPressed: canResubmit
|
||||
? () => _handleResubmit(flow)
|
||||
: null,
|
||||
child: const Text('재상신'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _canRecall(ApprovalFlow flow) {
|
||||
if (flow.status.isTerminal) {
|
||||
return false;
|
||||
}
|
||||
if (flow.steps.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return flow.steps.first.decidedAt == null;
|
||||
}
|
||||
|
||||
bool _canResubmit(ApprovalFlow flow) {
|
||||
if (!flow.status.isTerminal) {
|
||||
return false;
|
||||
}
|
||||
final statusName = flow.status.name.toLowerCase();
|
||||
return statusName.contains('반려') || statusName.contains('reject');
|
||||
}
|
||||
|
||||
Future<void> _handleRecall(ApprovalFlow flow) async {
|
||||
final latestFlow = await widget.controller.refreshFlow(flow.id!) ?? flow;
|
||||
final transactionUpdatedAt = latestFlow.transactionUpdatedAt;
|
||||
if (transactionUpdatedAt == null) {
|
||||
return;
|
||||
}
|
||||
final input = ApprovalRecallInput(
|
||||
approvalId: latestFlow.id!,
|
||||
actorId: widget.actorId,
|
||||
note: _trimmedOrNull(_recallNoteController.text),
|
||||
expectedUpdatedAt: latestFlow.approval.updatedAt,
|
||||
transactionExpectedUpdatedAt: transactionUpdatedAt,
|
||||
);
|
||||
await widget.controller.recallApproval(input);
|
||||
}
|
||||
|
||||
Future<void> _handleResubmit(ApprovalFlow flow) async {
|
||||
final latestFlow = await widget.controller.refreshFlow(flow.id!) ?? flow;
|
||||
final transactionUpdatedAt = latestFlow.transactionUpdatedAt;
|
||||
if (transactionUpdatedAt == null) {
|
||||
return;
|
||||
}
|
||||
final steps = latestFlow.steps
|
||||
.map(
|
||||
(step) => ApprovalStepAssignmentItem(
|
||||
stepOrder: step.stepOrder,
|
||||
approverId: step.approver.id,
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
final submission = ApprovalSubmissionInput(
|
||||
transactionId: latestFlow.transactionId,
|
||||
statusId: latestFlow.status.id,
|
||||
requesterId: latestFlow.requester.id,
|
||||
finalApproverId: latestFlow.finalApprover?.id,
|
||||
note: latestFlow.note,
|
||||
steps: steps,
|
||||
);
|
||||
final input = ApprovalResubmissionInput(
|
||||
approvalId: latestFlow.id!,
|
||||
actorId: widget.actorId,
|
||||
submission: submission,
|
||||
note: _trimmedOrNull(_resubmitNoteController.text),
|
||||
expectedUpdatedAt: latestFlow.approval.updatedAt,
|
||||
transactionExpectedUpdatedAt: transactionUpdatedAt,
|
||||
);
|
||||
await widget.controller.resubmitApproval(input);
|
||||
}
|
||||
|
||||
String? _trimmedOrNull(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_recallNoteController.dispose();
|
||||
_resubmitNoteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -18,7 +18,9 @@ void main() {
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
registerInventoryTestStubs();
|
||||
registerInventoryTestStubs(
|
||||
const InventoryTestStubConfig(registerProductRepository: true),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -18,7 +18,9 @@ void main() {
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
registerInventoryTestStubs();
|
||||
registerInventoryTestStubs(
|
||||
const InventoryTestStubConfig(registerProductRepository: true),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ void main() {
|
||||
_buildApp(
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
width: 320,
|
||||
child: CustomerPage(routeUri: Uri(path: '/masters/customers')),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -348,7 +348,7 @@ void main() {
|
||||
_buildApp(
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
width: 320,
|
||||
child: ProductPage(routeUri: Uri(path: '/masters/products')),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,7 +242,7 @@ void main() {
|
||||
_buildApp(
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
width: 320,
|
||||
child: VendorPage(routeUri: Uri(path: '/masters/vendors')),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -277,7 +277,7 @@ void main() {
|
||||
_buildApp(
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
width: 320,
|
||||
child: WarehousePage(routeUri: Uri(path: '/masters/warehouses')),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
237
test/fixtures/approvals/approval_five_step_pending.json
vendored
Normal file
237
test/fixtures/approvals/approval_five_step_pending.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
13
test/fixtures/approvals/approval_permissions.json
vendored
Normal file
13
test/fixtures/approvals/approval_permissions.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"viewer": [
|
||||
"view"
|
||||
],
|
||||
"approver": [
|
||||
"view",
|
||||
"approve"
|
||||
],
|
||||
"auditor": [
|
||||
"view",
|
||||
"restore"
|
||||
]
|
||||
}
|
||||
134
test/fixtures/approvals/approval_recalled.json
vendored
Normal file
134
test/fixtures/approvals/approval_recalled.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
153
test/fixtures/approvals/approval_rejected.json
vendored
Normal file
153
test/fixtures/approvals/approval_rejected.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
47
test/fixtures/approvals/approval_template_sample.json
vendored
Normal file
47
test/fixtures/approvals/approval_template_sample.json
vendored
Normal 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": "최종 승인"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
test/helpers/fixture_loader.dart
Normal file
15
test/helpers/fixture_loader.dart
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user