API v4 계약 반영하고 보고서·입출고 화면 실연동 강화

This commit is contained in:
JiWoong Sul
2025-10-16 14:57:07 +09:00
parent 7e0f7b1c55
commit d5c99627db
34 changed files with 1767 additions and 327 deletions

View File

@@ -0,0 +1,150 @@
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_repository_remote.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
class _MockApiClient extends Mock implements ApiClient {}
void main() {
late ApiClient apiClient;
late ApprovalRepositoryRemote repository;
setUpAll(() {
registerFallbackValue(Options());
registerFallbackValue(CancelToken());
});
setUp(() {
apiClient = _MockApiClient();
repository = ApprovalRepositoryRemote(apiClient: apiClient);
});
Map<String, dynamic> buildStep({
required int id,
required int order,
required int statusId,
String statusName = '대기',
}) {
return {
'id': id,
'approval_id': 5001,
'step_order': order,
'approver': {
'id': 20 + order,
'employee_no': 'E${order.toString().padLeft(4, '0')}',
'employee_name': '승인자$order',
},
'step_status': {
'id': statusId,
'status_name': statusName,
'is_blocking_next': true,
'is_terminal': false,
},
'assigned_at': '2025-09-18T06:00:00Z',
'decided_at': order == 1 ? '2025-09-18T08:05:00Z' : null,
'note': order == 1 ? '승인합니다.' : null,
};
}
Map<String, dynamic> buildHistory({
required int id,
required String timestamp,
}) {
return {
'id': id,
'approval_action_id': 1,
'approval_action_name': '승인',
'action_at': timestamp,
'note': '이력$id',
'from_status': {
'id': 1,
'status_name': '대기',
'is_blocking_next': true,
'is_terminal': false,
},
'to_status': {
'id': 2,
'status_name': '진행중',
'is_blocking_next': true,
'is_terminal': false,
},
};
}
test('performStepAction은 응답의 steps와 histories를 병합한다', () async {
const path = '/api/v1/approval-steps/7001/actions';
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': {
'approval': {
'id': 5001,
'approval_no': 'APP-2025-0001',
'status': {'id': 2, 'status_name': '진행중'},
'current_step': {'id': 7002, 'step_order': 2},
'requester': {
'id': 7,
'employee_no': 'E0007',
'employee_name': '김요청',
},
'requested_at': '2025-09-18T06:00:00Z',
'steps': [buildStep(id: 7001, order: 1, statusId: 1)],
'histories': [
buildHistory(id: 91000, timestamp: '2025-09-18T07:00:00Z'),
],
},
'steps': [
buildStep(id: 7001, order: 1, statusId: 2, statusName: '진행중'),
buildStep(id: 7002, order: 2, statusId: 1),
],
'step': buildStep(
id: 7001,
order: 1,
statusId: 2,
statusName: '진행중',
),
'next_step': buildStep(
id: 7002,
order: 2,
statusId: 3,
statusName: '대기',
),
'history': buildHistory(
id: 91001,
timestamp: '2025-09-18T08:05:00Z',
),
},
},
statusCode: 200,
requestOptions: RequestOptions(path: path),
),
);
final result = await repository.performStepAction(
ApprovalStepActionInput(stepId: 7001, actionId: 1, note: '승인합니다.'),
);
expect(result.id, 5001);
expect(result.steps.length, 2);
final firstStep = result.steps.firstWhere((step) => step.id == 7001);
expect(firstStep.status.id, 2);
expect(firstStep.status.name, '진행중');
final secondStep = result.steps.firstWhere((step) => step.id == 7002);
expect(secondStep.status.id, 3);
expect(result.histories.length, 2);
expect(result.histories.last.id, 91001);
});
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
@@ -8,6 +9,9 @@ import 'package:superport_v2/core/permissions/permission_manager.dart';
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
import 'package:superport_v2/features/inventory/inbound/presentation/pages/inbound_page.dart';
import 'package:superport_v2/features/inventory/shared/widgets/product_autocomplete_field.dart';
import 'package:superport_v2/widgets/components/form_field.dart';
import '../../helpers/inventory_test_stubs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -16,6 +20,14 @@ void main() {
await Environment.initialize();
});
setUp(() {
registerInventoryTestStubs();
});
tearDown(() async {
await GetIt.I.reset();
});
testWidgets('입고 필터 적용 및 초기화가 목록을 갱신한다', (tester) async {
final view = tester.view;
view.physicalSize = const Size(1280, 800);
@@ -95,6 +107,27 @@ void main() {
await tester.tap(find.widgetWithText(ShadButton, '입고 등록'));
await tester.pumpAndSettle();
final transactionField = find.byWidgetPredicate(
(widget) => widget is SuperportFormField && widget.label == '트랜잭션번호',
);
final approvalField = find.byWidgetPredicate(
(widget) => widget is SuperportFormField && widget.label == '결재번호',
);
expect(transactionField, findsOneWidget);
expect(approvalField, findsOneWidget);
final transactionInput = find.descendant(
of: transactionField,
matching: find.byType(EditableText),
);
final approvalInput = find.descendant(
of: approvalField,
matching: find.byType(EditableText),
);
await tester.enterText(transactionInput.first, 'IN-TEST-001');
await tester.enterText(approvalInput.first, 'APP-TEST-001');
await tester.pump();
final productFields = find.byType(InventoryProductAutocompleteField);
expect(productFields, findsWidgets);
@@ -105,7 +138,9 @@ void main() {
await tester.enterText(firstProductInput, 'XR-5000');
await tester.pump();
await tester.tap(find.widgetWithText(ShadButton, '품목 추가'));
final addLineButton = find.widgetWithText(ShadButton, '품목 추가');
await tester.ensureVisible(addLineButton);
await tester.tap(addLineButton);
await tester.pumpAndSettle();
final updatedProductFields = find.byType(InventoryProductAutocompleteField);
@@ -118,9 +153,47 @@ void main() {
await tester.enterText(secondProductInput, 'XR-5000');
await tester.pump();
await tester.tap(find.widgetWithText(ShadButton, '저장'));
final saveButton = find.widgetWithText(ShadButton, '저장');
await tester.ensureVisible(saveButton);
await tester.tap(saveButton);
await tester.pump();
expect(find.text('동일 제품이 중복되었습니다.'), findsOneWidget);
});
testWidgets('입고 등록 모달은 거래번호와 결재번호를 필수로 요구한다', (tester) async {
final view = tester.view;
view.physicalSize = const Size(1280, 900);
view.devicePixelRatio = 1.0;
addTearDown(() {
view.resetPhysicalSize();
view.resetDevicePixelRatio();
});
await tester.pumpWidget(
MaterialApp(
home: ScaffoldMessenger(
child: PermissionScope(
manager: PermissionManager(),
child: ShadTheme(
data: SuperportShadTheme.light(),
child: Scaffold(
body: InboundPage(routeUri: Uri.parse('/inventory/inbound')),
),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ShadButton, '입고 등록'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ShadButton, '저장'));
await tester.pump();
expect(find.text('거래번호를 입력하세요.'), findsOneWidget);
expect(find.text('결재번호를 입력하세요.'), findsOneWidget);
});
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/config/environment.dart';
import 'package:superport_v2/core/permissions/permission_manager.dart';
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
import 'package:superport_v2/features/inventory/outbound/presentation/pages/outbound_page.dart';
import '../../helpers/inventory_test_stubs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
await Environment.initialize();
});
setUp(() {
registerInventoryTestStubs();
});
tearDown(() async {
await GetIt.I.reset();
});
testWidgets('출고 등록 모달은 거래번호와 결재번호를 필수로 요구한다', (tester) async {
final view = tester.view;
view.physicalSize = const Size(1280, 900);
view.devicePixelRatio = 1.0;
addTearDown(() {
view.resetPhysicalSize();
view.resetDevicePixelRatio();
});
await tester.pumpWidget(
MaterialApp(
home: ScaffoldMessenger(
child: PermissionScope(
manager: PermissionManager(),
child: ShadTheme(
data: SuperportShadTheme.light(),
child: Scaffold(
body: OutboundPage(routeUri: Uri.parse('/inventory/outbound')),
),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ShadButton, '출고 등록'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ShadButton, '저장'));
await tester.pump();
expect(find.text('거래번호를 입력하세요.'), findsOneWidget);
expect(find.text('결재번호를 입력하세요.'), findsOneWidget);
});
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/config/environment.dart';
import 'package:superport_v2/core/permissions/permission_manager.dart';
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
import 'package:superport_v2/features/inventory/rental/presentation/pages/rental_page.dart';
import '../../helpers/inventory_test_stubs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
await Environment.initialize();
});
setUp(() {
registerInventoryTestStubs();
});
tearDown(() async {
await GetIt.I.reset();
});
testWidgets('대여 등록 모달은 거래번호와 결재번호를 필수로 요구한다', (tester) async {
final view = tester.view;
view.physicalSize = const Size(1280, 900);
view.devicePixelRatio = 1.0;
addTearDown(() {
view.resetPhysicalSize();
view.resetDevicePixelRatio();
});
await tester.pumpWidget(
MaterialApp(
home: ScaffoldMessenger(
child: PermissionScope(
manager: PermissionManager(),
child: ShadTheme(
data: SuperportShadTheme.light(),
child: Scaffold(
body: RentalPage(routeUri: Uri.parse('/inventory/rental')),
),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ShadButton, '대여 등록'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ShadButton, '저장'));
await tester.pump();
expect(find.text('거래번호를 입력하세요.'), findsOneWidget);
expect(find.text('결재번호를 입력하세요.'), findsOneWidget);
});
}

View File

@@ -22,38 +22,19 @@ void main() {
repository = TransactionCustomerRepositoryRemote(apiClient: apiClient);
});
Map<String, dynamic> customerResponse() {
return {
'data': {
'customers': [
{
'id': 301,
'customer': {
'id': 700,
'customer_code': 'C-1',
'customer_name': '슈퍼포트',
},
'note': '테스트',
},
],
},
};
}
test('addCustomers는 거래 ID를 포함해 POST 요청을 보낸다', () async {
const path = '/api/v1/stock-transactions/77/customers';
when(
() => apiClient.post<Map<String, dynamic>>(
() => apiClient.post<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: customerResponse(),
(_) async => Response<void>(
requestOptions: RequestOptions(path: path),
statusCode: 200,
statusCode: 204,
),
);
@@ -63,7 +44,7 @@ void main() {
final payload =
verify(
() => apiClient.post<Map<String, dynamic>>(
() => apiClient.post<void>(
captureAny(),
data: captureAny(named: 'data'),
options: any(named: 'options'),
@@ -79,17 +60,16 @@ void main() {
test('updateCustomers는 PATCH 요청을 보낸다', () async {
const path = '/api/v1/stock-transactions/77/customers';
when(
() => apiClient.patch<Map<String, dynamic>>(
() => apiClient.patch<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: customerResponse(),
(_) async => Response<void>(
requestOptions: RequestOptions(path: path),
statusCode: 200,
statusCode: 204,
),
);
@@ -98,7 +78,7 @@ void main() {
]);
verify(
() => apiClient.patch<Map<String, dynamic>>(
() => apiClient.patch<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),

View File

@@ -22,36 +22,19 @@ void main() {
repository = TransactionLineRepositoryRemote(apiClient: apiClient);
});
Map<String, dynamic> lineResponse() {
return {
'data': {
'lines': [
{
'id': 101,
'line_no': 1,
'product': {'id': 11, 'product_code': 'P-1', 'product_name': '품목'},
'quantity': 3,
'unit_price': 1000,
},
],
},
};
}
test('addLines는 거래 ID를 포함한 POST 요청을 보낸다', () async {
const path = '/api/v1/stock-transactions/50/lines';
when(
() => apiClient.post<Map<String, dynamic>>(
() => apiClient.post<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: lineResponse(),
(_) async => Response<void>(
requestOptions: RequestOptions(path: path),
statusCode: 200,
statusCode: 204,
),
);
@@ -66,7 +49,7 @@ void main() {
final payload =
verify(
() => apiClient.post<Map<String, dynamic>>(
() => apiClient.post<void>(
captureAny(),
data: captureAny(named: 'data'),
options: any(named: 'options'),
@@ -82,17 +65,16 @@ void main() {
test('updateLines는 PATCH 요청을 사용한다', () async {
const path = '/api/v1/stock-transactions/50/lines';
when(
() => apiClient.patch<Map<String, dynamic>>(
() => apiClient.patch<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: lineResponse(),
(_) async => Response<void>(
requestOptions: RequestOptions(path: path),
statusCode: 200,
statusCode: 204,
),
);
@@ -101,7 +83,7 @@ void main() {
]);
verify(
() => apiClient.patch<Map<String, dynamic>>(
() => apiClient.patch<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
@@ -141,31 +123,28 @@ void main() {
test('restoreLine은 복구 엔드포인트를 호출한다', () async {
const path = '/api/v1/transaction-lines/101/restore';
when(
() => apiClient.post<Map<String, dynamic>>(
() => apiClient.post<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {
'data': {
'id': 101,
'line_no': 1,
'product': {'id': 11, 'product_code': 'P-1', 'product_name': '품목'},
'quantity': 3,
'unit_price': 1000,
},
},
(_) async => Response<void>(
requestOptions: RequestOptions(path: path),
statusCode: 200,
statusCode: 204,
),
);
final line = await repository.restoreLine(101);
await repository.restoreLine(101);
expect(line.id, 101);
expect(line.lineNo, 1);
verify(
() => apiClient.post<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).called(1);
});
}

View File

@@ -80,9 +80,9 @@ void main() {
from: DateTime(2024, 1, 1),
to: DateTime(2024, 1, 31),
format: ReportExportFormat.xlsx,
transactionTypeId: 3,
statusId: 1,
warehouseId: 9,
transactionStatusId: 3,
approvalStatusId: 7,
requestedById: 9,
);
final result = await repository.exportTransactions(request);
@@ -101,9 +101,9 @@ void main() {
expect(query['from'], request.from.toIso8601String());
expect(query['to'], request.to.toIso8601String());
expect(query['format'], 'xlsx');
expect(query['type_id'], 3);
expect(query['status_id'], 1);
expect(query['warehouse_id'], 9);
expect(query['transaction_status_id'], 3);
expect(query['approval_status_id'], 7);
expect(query['requested_by_id'], 9);
expect(result.downloadUrl.toString(), 'https://example.com/report.xlsx');
expect(result.filename, 'report.xlsx');
@@ -138,7 +138,7 @@ void main() {
from: DateTime(2024, 2, 1),
to: DateTime(2024, 2, 15),
format: ReportExportFormat.pdf,
statusId: 5,
approvalStatusId: 5,
);
final result = await repository.exportApprovals(request);

View File

@@ -15,8 +15,6 @@ import 'package:superport_v2/features/reporting/domain/entities/report_export_fo
import 'package:superport_v2/features/reporting/domain/entities/report_export_request.dart';
import 'package:superport_v2/features/reporting/domain/repositories/reporting_repository.dart';
import 'package:superport_v2/features/reporting/presentation/pages/reporting_page.dart';
import 'package:superport_v2/widgets/components/empty_state.dart';
import '../../helpers/test_app.dart';
void main() {

View File

@@ -349,27 +349,27 @@ class _StubTransactionLineRepository implements TransactionLineRepository {
}
@override
Future<List<StockTransactionLine>> addLines(
Future<void> addLines(
int transactionId,
List<TransactionLineCreateInput> lines,
) async {
return _linesFor(transactionId);
_linesFor(transactionId);
}
@override
Future<void> deleteLine(int lineId) async {}
@override
Future<List<StockTransactionLine>> updateLines(
Future<void> updateLines(
int transactionId,
List<TransactionLineUpdateInput> lines,
) async {
return _linesFor(transactionId);
_linesFor(transactionId);
}
@override
Future<StockTransactionLine> restoreLine(int lineId) async {
return _findLine(lineId);
Future<void> restoreLine(int lineId) async {
_findLine(lineId);
}
}
@@ -390,22 +390,22 @@ class _StubTransactionCustomerRepository
}
@override
Future<List<StockTransactionCustomer>> addCustomers(
Future<void> addCustomers(
int transactionId,
List<TransactionCustomerCreateInput> customers,
) async {
return _customersFor(transactionId);
_customersFor(transactionId);
}
@override
Future<void> deleteCustomer(int customerLinkId) async {}
@override
Future<List<StockTransactionCustomer>> updateCustomers(
Future<void> updateCustomers(
int transactionId,
List<TransactionCustomerUpdateInput> customers,
) async {
return _customersFor(transactionId);
_customersFor(transactionId);
}
}