feat: 재고 상태 전이 플래그 적용 및 실패 메시지 정비
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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/api_error.dart';
|
||||
import 'package:superport_v2/core/network/failure.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';
|
||||
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
|
||||
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||
|
||||
class _MockStockTransactionRepository extends Mock
|
||||
implements StockTransactionRepository {}
|
||||
|
||||
class _MockInventoryLookupRepository extends Mock
|
||||
implements InventoryLookupRepository {}
|
||||
|
||||
class _MockTransactionLineRepository extends Mock
|
||||
implements TransactionLineRepository {}
|
||||
|
||||
class _FakeStockTransactionCreateInput extends Fake
|
||||
implements StockTransactionCreateInput {}
|
||||
|
||||
class _FakeStockTransactionUpdateInput extends Fake
|
||||
implements StockTransactionUpdateInput {}
|
||||
|
||||
class _FakeStockTransactionListFilter extends Fake
|
||||
implements StockTransactionListFilter {}
|
||||
|
||||
void main() {
|
||||
group('InboundController', () {
|
||||
late StockTransactionRepository transactionRepository;
|
||||
late InventoryLookupRepository lookupRepository;
|
||||
late TransactionLineRepository lineRepository;
|
||||
late InboundController controller;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(_FakeStockTransactionCreateInput());
|
||||
registerFallbackValue(_FakeStockTransactionUpdateInput());
|
||||
registerFallbackValue(_FakeStockTransactionListFilter());
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
transactionRepository = _MockStockTransactionRepository();
|
||||
lookupRepository = _MockInventoryLookupRepository();
|
||||
lineRepository = _MockTransactionLineRepository();
|
||||
controller = InboundController(
|
||||
transactionRepository: transactionRepository,
|
||||
lineRepository: lineRepository,
|
||||
lookupRepository: lookupRepository,
|
||||
);
|
||||
});
|
||||
|
||||
test('createTransaction은 레코드를 추가하고 결과를 반환한다', () async {
|
||||
final transaction = _buildTransaction();
|
||||
when(
|
||||
() => transactionRepository.create(any()),
|
||||
).thenAnswer((_) async => transaction);
|
||||
|
||||
final input = StockTransactionCreateInput(
|
||||
transactionTypeId: 1,
|
||||
transactionStatusId: 2,
|
||||
warehouseId: 3,
|
||||
transactionDate: DateTime(2024, 3, 1),
|
||||
createdById: 9,
|
||||
);
|
||||
|
||||
final record = await controller.createTransaction(
|
||||
input,
|
||||
refreshAfter: false,
|
||||
);
|
||||
|
||||
expect(record.id, equals(transaction.id));
|
||||
expect(controller.records.length, equals(1));
|
||||
expect(controller.records.first.id, equals(transaction.id));
|
||||
verify(() => transactionRepository.create(any())).called(1);
|
||||
});
|
||||
|
||||
test('updateTransaction은 로컬 레코드를 갱신한다', () async {
|
||||
final original = _buildTransaction();
|
||||
final updated = _buildTransaction(statusName: '승인완료');
|
||||
when(
|
||||
() => transactionRepository.create(any()),
|
||||
).thenAnswer((_) async => original);
|
||||
when(
|
||||
() => transactionRepository.update(any(), any()),
|
||||
).thenAnswer((_) async => updated);
|
||||
|
||||
await controller.createTransaction(
|
||||
StockTransactionCreateInput(
|
||||
transactionTypeId: 1,
|
||||
transactionStatusId: 2,
|
||||
warehouseId: 3,
|
||||
transactionDate: DateTime(2024, 3, 1),
|
||||
createdById: 9,
|
||||
),
|
||||
refreshAfter: false,
|
||||
);
|
||||
|
||||
final future = controller.updateTransaction(
|
||||
original.id!,
|
||||
StockTransactionUpdateInput(transactionStatusId: 3),
|
||||
refreshAfter: false,
|
||||
);
|
||||
|
||||
expect(controller.processingTransactionIds.contains(original.id), isTrue);
|
||||
|
||||
final record = await future;
|
||||
|
||||
expect(record.status, equals('승인완료'));
|
||||
expect(controller.records.first.status, equals('승인완료'));
|
||||
expect(
|
||||
controller.processingTransactionIds.contains(original.id),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('deleteTransaction은 레코드를 제거한다', () async {
|
||||
final transaction = _buildTransaction();
|
||||
when(
|
||||
() => transactionRepository.create(any()),
|
||||
).thenAnswer((_) async => transaction);
|
||||
when(
|
||||
() => transactionRepository.delete(any()),
|
||||
).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await controller.createTransaction(
|
||||
StockTransactionCreateInput(
|
||||
transactionTypeId: 1,
|
||||
transactionStatusId: 2,
|
||||
warehouseId: 3,
|
||||
transactionDate: DateTime(2024, 3, 1),
|
||||
createdById: 9,
|
||||
),
|
||||
refreshAfter: false,
|
||||
);
|
||||
|
||||
final future = controller.deleteTransaction(
|
||||
transaction.id!,
|
||||
refreshAfter: false,
|
||||
);
|
||||
|
||||
expect(
|
||||
controller.processingTransactionIds.contains(transaction.id),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
await future;
|
||||
|
||||
expect(controller.records, isEmpty);
|
||||
expect(
|
||||
controller.processingTransactionIds.contains(transaction.id),
|
||||
isFalse,
|
||||
);
|
||||
verify(() => transactionRepository.delete(transaction.id!)).called(1);
|
||||
});
|
||||
|
||||
test('submitTransaction은 refreshAfter가 true일 때 목록을 다시 불러온다', () async {
|
||||
final filter = StockTransactionListFilter(transactionTypeId: 1);
|
||||
final initial = _buildTransaction();
|
||||
when(
|
||||
() => transactionRepository.list(filter: any(named: 'filter')),
|
||||
).thenAnswer(
|
||||
(_) async => PaginatedResult<StockTransaction>(
|
||||
items: [initial],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
),
|
||||
);
|
||||
await controller.fetchTransactions(filter: filter);
|
||||
|
||||
final updated = _buildTransaction(statusName: '승인대기');
|
||||
when(
|
||||
() => transactionRepository.submit(any()),
|
||||
).thenAnswer((_) async => updated);
|
||||
when(
|
||||
() => transactionRepository.list(filter: any(named: 'filter')),
|
||||
).thenAnswer(
|
||||
(_) async => PaginatedResult<StockTransaction>(
|
||||
items: [updated],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
),
|
||||
);
|
||||
|
||||
final result = await controller.submitTransaction(initial.id!);
|
||||
|
||||
expect(result.status, equals('승인대기'));
|
||||
expect(controller.records.first.status, equals('승인대기'));
|
||||
verify(() => transactionRepository.submit(initial.id!)).called(1);
|
||||
});
|
||||
|
||||
test('fetchTransactions 실패 시 Failure 메시지를 노출한다', () async {
|
||||
final exception = ApiException(
|
||||
code: ApiErrorCode.unprocessableEntity,
|
||||
message: '입고 목록을 불러오지 못했습니다.',
|
||||
details: {
|
||||
'errors': {
|
||||
'transaction_date': ['처리일자를 선택해 주세요.'],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
when(
|
||||
() => transactionRepository.list(filter: any(named: 'filter')),
|
||||
).thenThrow(exception);
|
||||
|
||||
await controller.fetchTransactions(
|
||||
filter: StockTransactionListFilter(transactionTypeId: 1),
|
||||
);
|
||||
|
||||
final failure = Failure.from(exception);
|
||||
expect(controller.errorMessage, equals(failure.describe()));
|
||||
expect(controller.records, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
StockTransaction _buildTransaction({int id = 100, String statusName = '작성중'}) {
|
||||
return StockTransaction(
|
||||
id: id,
|
||||
transactionNo: 'TX-$id',
|
||||
transactionDate: DateTime(2024, 3, 1),
|
||||
type: StockTransactionType(id: 10, name: '입고'),
|
||||
status: StockTransactionStatus(id: 11, name: statusName),
|
||||
warehouse: StockTransactionWarehouse(id: 1, code: 'WH', name: '서울 물류'),
|
||||
createdBy: StockTransactionEmployee(
|
||||
id: 1,
|
||||
employeeNo: 'EMP-1',
|
||||
name: '관리자',
|
||||
),
|
||||
lines: [
|
||||
StockTransactionLine(
|
||||
id: 1,
|
||||
lineNo: 1,
|
||||
product: StockTransactionProduct(id: 1, code: 'P-1', name: '테스트 상품'),
|
||||
quantity: 5,
|
||||
unitPrice: 1000.0,
|
||||
),
|
||||
],
|
||||
customers: const [],
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,19 @@ import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/config/environment.dart';
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/network/api_error.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.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 'package:superport_v2/features/reporting/domain/entities/report_download_result.dart';
|
||||
import 'package:superport_v2/features/reporting/domain/entities/report_export_format.dart';
|
||||
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';
|
||||
|
||||
@@ -25,6 +33,10 @@ void main() {
|
||||
testWidgets('보고서 화면은 창고 목록 재시도 흐름을 제공한다', (tester) async {
|
||||
final repo = _FlakyWarehouseRepository();
|
||||
GetIt.I.registerSingleton<WarehouseRepository>(repo);
|
||||
GetIt.I.registerSingleton<InventoryLookupRepository>(
|
||||
_StubLookupRepository(),
|
||||
);
|
||||
GetIt.I.registerSingleton<ReportingRepository>(_FakeReportingRepository());
|
||||
|
||||
final view = tester.view;
|
||||
view.physicalSize = const Size(1280, 800);
|
||||
@@ -38,13 +50,16 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repo.attempts, 1);
|
||||
expect(find.text('창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'), findsOneWidget);
|
||||
expect(
|
||||
find.text('창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'),
|
||||
findsWidgets,
|
||||
);
|
||||
|
||||
await tester.tap(find.widgetWithText(ShadButton, '재시도'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
|
||||
expect(repo.attempts, 2);
|
||||
expect(find.text('창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,10 +72,14 @@ class _FlakyWarehouseRepository implements WarehouseRepository {
|
||||
int pageSize = 20,
|
||||
String? query,
|
||||
bool? isActive,
|
||||
bool includeZipcode = true,
|
||||
}) async {
|
||||
attempts += 1;
|
||||
if (attempts == 1) {
|
||||
throw Exception('network down');
|
||||
throw const ApiException(
|
||||
code: ApiErrorCode.network,
|
||||
message: '창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.',
|
||||
);
|
||||
}
|
||||
return PaginatedResult<Warehouse>(
|
||||
items: [
|
||||
@@ -98,3 +117,77 @@ class _FlakyWarehouseRepository implements WarehouseRepository {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
class _StubLookupRepository implements InventoryLookupRepository {
|
||||
@override
|
||||
Future<List<LookupItem>> fetchTransactionTypes({
|
||||
bool activeOnly = true,
|
||||
}) async {
|
||||
return [
|
||||
LookupItem(id: 1, name: '입고'),
|
||||
LookupItem(id: 2, name: '출고'),
|
||||
LookupItem(id: 3, name: '대여'),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<LookupItem>> fetchTransactionStatuses({
|
||||
bool activeOnly = true,
|
||||
}) async {
|
||||
return [
|
||||
LookupItem(id: 11, name: '작성중'),
|
||||
LookupItem(id: 12, name: '완료'),
|
||||
LookupItem(id: 13, name: '취소'),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<LookupItem>> fetchApprovalStatuses({
|
||||
bool activeOnly = true,
|
||||
}) async {
|
||||
return [
|
||||
LookupItem(id: 21, name: '진행중'),
|
||||
LookupItem(id: 22, name: '완료'),
|
||||
LookupItem(id: 23, name: '취소'),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<LookupItem>> fetchApprovalActions({
|
||||
bool activeOnly = true,
|
||||
}) async {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeReportingRepository implements ReportingRepository {
|
||||
ReportExportRequest? lastRequest;
|
||||
ReportExportFormat? lastFormat;
|
||||
|
||||
@override
|
||||
Future<ReportDownloadResult> exportApprovals(
|
||||
ReportExportRequest request,
|
||||
) async {
|
||||
lastRequest = request;
|
||||
lastFormat = request.format;
|
||||
return ReportDownloadResult(
|
||||
downloadUrl: Uri.parse('https://example.com/approvals.pdf'),
|
||||
filename: 'approvals.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ReportDownloadResult> exportTransactions(
|
||||
ReportExportRequest request,
|
||||
) async {
|
||||
lastRequest = request;
|
||||
lastFormat = request.format;
|
||||
return ReportDownloadResult(
|
||||
downloadUrl: Uri.parse('https://example.com/transactions.xlsx'),
|
||||
filename: 'transactions.xlsx',
|
||||
mimeType:
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user