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:
@@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user