Files
superport_v2/integration_test/stock_transaction_state_flow_test.dart
JiWoong Sul 3e83408aa7 feat(approvals): 결재 접근 차단 대응과 전표 전이 메모 전달 강화
- approvals 모듈에서 APPROVAL_ACCESS_DENIED 응답을 포착하여 ApprovalAccessDeniedException으로 변환하고 접근 거부 시 토스트·대시보드 리다이렉트를 처리

- approval history 조회가 서버 action id에 맞춰 필터링되도록 repository·controller·테스트를 보강

- 재고 트랜잭션 상태 전이 API 호출에 note를 전달하도록 repository·컨트롤러·통합/단위 테스트를 업데이트

- 승인 플로우 QA 체크리스트 및 연동 문서를 최신 계약과 테스트 흐름으로 업데이트
2025-10-31 16:43:14 +09:00

454 lines
14 KiB
Dart

import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.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';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const baseUrl = String.fromEnvironment('STAGING_API_BASE_URL');
const token = String.fromEnvironment('STAGING_API_TOKEN');
const runFlow = bool.fromEnvironment('STAGING_RUN_TRANSACTION_FLOW');
const useFakeFlow = bool.fromEnvironment('STAGING_USE_FAKE_FLOW');
const transactionTypeId = int.fromEnvironment(
'STAGING_TRANSACTION_TYPE_ID',
defaultValue: 0,
);
const transactionStatusId = int.fromEnvironment(
'STAGING_TRANSACTION_STATUS_ID',
defaultValue: 0,
);
const warehouseId = int.fromEnvironment(
'STAGING_WAREHOUSE_ID',
defaultValue: 0,
);
const employeeId = int.fromEnvironment(
'STAGING_EMPLOYEE_ID',
defaultValue: 0,
);
const productId = int.fromEnvironment('STAGING_PRODUCT_ID', defaultValue: 0);
const customerId = int.fromEnvironment(
'STAGING_CUSTOMER_ID',
defaultValue: 0,
);
final missingConfigs = <String>[];
if (!runFlow) {
missingConfigs.add('STAGING_RUN_TRANSACTION_FLOW=true');
}
if (!useFakeFlow) {
if (baseUrl.isEmpty) {
missingConfigs.add('STAGING_API_BASE_URL');
}
if (token.isEmpty) {
missingConfigs.add('STAGING_API_TOKEN');
}
if (transactionTypeId == 0) {
missingConfigs.add('STAGING_TRANSACTION_TYPE_ID');
}
if (transactionStatusId == 0) {
missingConfigs.add('STAGING_TRANSACTION_STATUS_ID');
}
if (warehouseId == 0) {
missingConfigs.add('STAGING_WAREHOUSE_ID');
}
if (employeeId == 0) {
missingConfigs.add('STAGING_EMPLOYEE_ID');
}
if (productId == 0) {
missingConfigs.add('STAGING_PRODUCT_ID');
}
if (customerId == 0) {
missingConfigs.add('STAGING_CUSTOMER_ID');
}
}
if (missingConfigs.isNotEmpty) {
final reason = '환경 변수를 설정하세요: ${missingConfigs.join(', ')}';
testWidgets(
'staging transaction flow (환경 변수 설정 필요: STAGING_RUN_TRANSACTION_FLOW 및 API 식별자)',
(tester) async {
tester.printToConsole('통합 테스트를 실행하려면 다음 값을 설정하세요.\n$reason');
},
skip: true,
);
return;
}
testWidgets('stock transaction end-to-end flow succeeds', (tester) async {
final resolvedTransactionTypeId = transactionTypeId == 0
? 100
: transactionTypeId;
final resolvedTransactionStatusId = transactionStatusId == 0
? 10
: transactionStatusId;
final resolvedWarehouseId = warehouseId == 0 ? 1 : warehouseId;
final resolvedEmployeeId = employeeId == 0 ? 1 : employeeId;
final resolvedProductId = productId == 0 ? 1 : productId;
final resolvedCustomerId = customerId == 0 ? 1 : customerId;
late final StockTransactionRepository repository;
if (useFakeFlow) {
repository = _FakeStockTransactionRepository(
transactionTypeId: resolvedTransactionTypeId,
initialStatusId: resolvedTransactionStatusId,
warehouseId: resolvedWarehouseId,
employeeId: resolvedEmployeeId,
productId: resolvedProductId,
customerId: resolvedCustomerId,
);
} else {
final dio = Dio(
BaseOptions(
baseUrl: baseUrl,
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
),
);
final apiClient = ApiClient(dio: dio);
repository = StockTransactionRepositoryRemote(apiClient: apiClient);
}
final now = DateTime.now();
StockTransactionCreateInput buildCreateInput(String label) {
final suffix = '$label-${now.millisecondsSinceEpoch}';
return StockTransactionCreateInput(
transactionTypeId: resolvedTransactionTypeId,
transactionStatusId: resolvedTransactionStatusId,
warehouseId: resolvedWarehouseId,
transactionDate: now,
createdById: resolvedEmployeeId,
note: 'integration-test $suffix',
lines: [
TransactionLineCreateInput(
lineNo: 1,
productId: resolvedProductId,
quantity: 1,
unitPrice: 1,
note: 'line $suffix',
),
],
customers: [
TransactionCustomerCreateInput(customerId: resolvedCustomerId),
],
approval: StockTransactionApprovalInput(
requestedById: resolvedEmployeeId,
note: 'approval $suffix',
),
);
}
bool statusChanged(StockTransaction before, StockTransaction after) {
if (before.status.id != after.status.id) {
return true;
}
final beforeName = before.status.name.trim();
final afterName = after.status.name.trim();
return beforeName != afterName;
}
Future<void> safeDelete(int id) async {
try {
await repository.delete(id);
tester.printToConsole('deleted transaction: $id');
} catch (error) {
tester.printToConsole(
'삭제 중 경고: transaction $id 제거 실패 (${error.runtimeType})',
);
}
}
final primary = await repository.create(buildCreateInput('primary'));
expect(primary.id, isNotNull);
final primaryId = primary.id!;
tester.printToConsole('created transaction(primary): $primaryId');
final submitted = await repository.submit(
primaryId,
note: 'integration-submit-primary',
);
expect(submitted.id, equals(primaryId));
if (useFakeFlow) {
expect(submitted.status.name, contains('승인대기'));
} else {
expect(statusChanged(primary, submitted), isTrue);
}
tester.printToConsole(
'submitted transaction: $primaryId (status: ${submitted.status.name})',
);
final approved = await repository.approve(
primaryId,
note: 'integration-approve-primary',
);
expect(approved.id, equals(primaryId));
if (useFakeFlow) {
expect(approved.status.name, contains('승인완료'));
} else {
expect(statusChanged(submitted, approved), isTrue);
}
tester.printToConsole(
'approved transaction: $primaryId (status: ${approved.status.name})',
);
final completed = await repository.complete(
primaryId,
note: 'integration-complete-primary',
);
expect(completed.id, equals(primaryId));
if (useFakeFlow) {
expect(completed.status.name, contains('완료'));
} else {
expect(statusChanged(approved, completed), isTrue);
}
tester.printToConsole(
'completed transaction: $primaryId (status: ${completed.status.name})',
);
await safeDelete(primaryId);
final cancelTarget = await repository.create(buildCreateInput('cancel'));
expect(cancelTarget.id, isNotNull);
final cancelId = cancelTarget.id!;
tester.printToConsole('created transaction(cancel): $cancelId');
final cancelSubmitted = await repository.submit(
cancelId,
note: 'integration-submit-cancel',
);
expect(cancelSubmitted.id, equals(cancelId));
final cancelled = await repository.cancel(
cancelId,
note: 'integration-cancel',
);
expect(cancelled.id, equals(cancelId));
if (useFakeFlow) {
expect(cancelled.status.name, contains('취소'));
} else {
expect(statusChanged(cancelSubmitted, cancelled), isTrue);
}
tester.printToConsole(
'cancelled transaction: $cancelId (status: ${cancelled.status.name})',
);
await safeDelete(cancelId);
final rejectTarget = await repository.create(buildCreateInput('reject'));
expect(rejectTarget.id, isNotNull);
final rejectId = rejectTarget.id!;
tester.printToConsole('created transaction(reject): $rejectId');
final rejectSubmitted = await repository.submit(
rejectId,
note: 'integration-submit-reject',
);
expect(rejectSubmitted.id, equals(rejectId));
final rejected = await repository.reject(
rejectId,
note: 'integration-reject',
);
expect(rejected.id, equals(rejectId));
if (useFakeFlow) {
expect(rejected.status.name, contains('반려'));
} else {
expect(statusChanged(rejectSubmitted, rejected), isTrue);
}
tester.printToConsole(
'rejected transaction: $rejectId (status: ${rejected.status.name})',
);
await safeDelete(rejectId);
});
}
class _FakeStockTransactionRepository implements StockTransactionRepository {
_FakeStockTransactionRepository({
required this.transactionTypeId,
required this.initialStatusId,
required this.warehouseId,
required this.employeeId,
required this.productId,
required this.customerId,
});
final int transactionTypeId;
final int initialStatusId;
final int warehouseId;
final int employeeId;
final int productId;
final int customerId;
int _sequence = 1;
final Map<int, StockTransaction> _transactions = {};
@override
Future<StockTransaction> create(StockTransactionCreateInput input) async {
final id = _sequence++;
final generatedNo = 'FAKE-${id.toString().padLeft(6, '0')}';
final transaction = StockTransaction(
id: id,
transactionNo: generatedNo,
transactionDate: input.transactionDate,
type: StockTransactionType(id: input.transactionTypeId, name: '테스트 트랜잭션'),
status: StockTransactionStatus(id: initialStatusId, name: '작성중'),
warehouse: StockTransactionWarehouse(
id: warehouseId,
code: 'WH-$warehouseId',
name: '테스트 창고',
),
createdBy: StockTransactionEmployee(
id: employeeId,
employeeNo: 'EMP-$employeeId',
name: '통합 테스트 사용자',
),
note: input.note,
isActive: true,
lines: input.lines
.map(
(line) => StockTransactionLine(
id: line.lineNo,
lineNo: line.lineNo,
product: StockTransactionProduct(
id: line.productId,
code: 'P-${line.productId}',
name: '테스트 상품',
),
quantity: line.quantity,
unitPrice: line.unitPrice,
note: line.note,
),
)
.toList(growable: false),
customers: input.customers
.map(
(customer) => StockTransactionCustomer(
id: customer.customerId,
customer: StockTransactionCustomerSummary(
id: customer.customerId,
code: 'C-${customer.customerId}',
name: '테스트 고객',
),
note: customer.note,
),
)
.toList(growable: false),
expectedReturnDate: input.expectedReturnDate,
);
_transactions[id] = transaction;
return transaction;
}
@override
Future<StockTransaction> submit(int id, {String? note}) async {
return _updateStatus(
id,
StockTransactionStatus(id: initialStatusId + 1, name: '승인대기'),
);
}
@override
Future<StockTransaction> complete(int id, {String? note}) async {
return _updateStatus(
id,
StockTransactionStatus(id: initialStatusId + 2, name: '완료'),
);
}
@override
Future<StockTransaction> approve(int id, {String? note}) async {
return _updateStatus(
id,
StockTransactionStatus(id: initialStatusId + 3, name: '승인완료'),
);
}
@override
Future<StockTransaction> reject(int id, {String? note}) async {
return _updateStatus(
id,
StockTransactionStatus(id: initialStatusId + 4, name: '반려'),
);
}
@override
Future<StockTransaction> cancel(int id, {String? note}) async {
return _updateStatus(
id,
StockTransactionStatus(id: initialStatusId + 5, name: '취소'),
);
}
@override
Future<void> delete(int id) async {
_transactions.remove(id);
}
@override
Future<StockTransaction> restore(int id) async {
return _require(id);
}
@override
Future<StockTransaction> update(
int id,
StockTransactionUpdateInput input,
) async {
final current = _require(id);
final updated = current.copyWith(
status: StockTransactionStatus(
id: input.transactionStatusId,
name: '상태${input.transactionStatusId}',
),
note: input.note ?? current.note,
expectedReturnDate:
input.expectedReturnDate ?? current.expectedReturnDate,
);
_transactions[id] = updated;
return updated;
}
@override
Future<StockTransaction> fetchDetail(
int id, {
List<String> include = const ['lines', 'customers', 'approval'],
}) async {
return _require(id);
}
@override
Future<PaginatedResult<StockTransaction>> list({
StockTransactionListFilter? filter,
}) async {
final items = _transactions.values.toList(growable: false);
return PaginatedResult<StockTransaction>(
items: items,
page: filter?.page ?? 1,
pageSize: filter?.pageSize ?? items.length,
total: items.length,
);
}
StockTransaction _updateStatus(int id, StockTransactionStatus status) {
final current = _require(id);
final updated = current.copyWith(status: status);
_transactions[id] = updated;
return updated;
}
StockTransaction _require(int id) {
final transaction = _transactions[id];
if (transaction == null) {
throw StateError('Transaction $id not found');
}
return transaction;
}
}