chore: 통합 테스트 환경과 보고서 리모트 구성
This commit is contained in:
10
CHANGELOG.md
Normal file
10
CHANGELOG.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 변경 기록
|
||||
|
||||
## 2025-10-20
|
||||
- 재고 입·출·대여 컨트롤러가 `Failure.describe()` 기반으로 오류를 노출해 승인/취소 흐름에서 서버 메시지가 그대로 전달됩니다.
|
||||
- 우편번호 검색 다이얼로그와 창고 선택 위젯이 API 예외를 상세히 표기하며, 관련 위젯 테스트를 추가했습니다.
|
||||
- 승인/재고 플로우 주요 컨트롤러 단위 테스트에 실패 메시지 검증을 포함해 회귀를 방지합니다.
|
||||
|
||||
## 2025-10-18
|
||||
- 재고 트랜잭션 목록/상세/상태 전이를 실 API(`StockTransactionRepositoryRemote`)에 연결하고 보고서 다운로드 UX를 개선했습니다.
|
||||
- 결재 도메인에서 `canProceed` API 검증과 기능 플래그 기본값 조정으로 사용성 이슈를 해소했습니다.
|
||||
50
doc/qa/staging_transaction_flow.md
Normal file
50
doc/qa/staging_transaction_flow.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 스테이징 재고 플로우 검증 시나리오
|
||||
|
||||
## 전제 조건
|
||||
- 스테이징 API 서버가 기동되어 있고, 테스트용 토큰이 발급되어 있어야 합니다.
|
||||
- 아래 환경 변수에 대응하는 실 ID가 존재해야 합니다.
|
||||
- `STAGING_API_BASE_URL`
|
||||
- `STAGING_API_TOKEN`
|
||||
- `STAGING_TRANSACTION_TYPE_ID`
|
||||
- `STAGING_TRANSACTION_STATUS_ID`
|
||||
- `STAGING_WAREHOUSE_ID`
|
||||
- `STAGING_EMPLOYEE_ID`
|
||||
- `STAGING_PRODUCT_ID`
|
||||
- `STAGING_CUSTOMER_ID`
|
||||
- 임시 데이터 생성/삭제가 허용된 스테이징 계정을 사용하세요.
|
||||
|
||||
## 테스트 데이터 구성
|
||||
- 스테이징 콘솔 또는 API를 사용해 전용 QA 데이터를 사전에 확보합니다.
|
||||
1. `/api/v1/warehouses`, `/api/v1/employees`, `/api/v1/products`, `/api/v1/customers` 목록을 조회해 테스트에 사용할 고정 ID를 선택하거나 새 레코드를 생성합니다.
|
||||
2. `/api/v1/transaction-types`와 `/api/v1/transaction-statuses`에서 submit/approve/complete 플로우를 지원하는 타입·상태 조합을 확인합니다.
|
||||
3. `/api/v1/stock-transactions`에 임시 트랜잭션을 생성해 라인/고객/승인 데이터가 정상 저장되는지 점검한 뒤, 성공한 요청 본문을 재사용 가능한 fixture로 저장합니다.
|
||||
4. 테스트 종료 후 `/api/v1/stock-transactions/{id}` DELETE 요청으로 데이터를 정리해 다른 시나리오에 영향을 주지 않도록 합니다.
|
||||
- 각 ID는 `.env.staging` 또는 CI 시크릿에 명시해 실행 시점에 자동으로 주입합니다.
|
||||
|
||||
## 자동화 테스트 실행 방법
|
||||
1. 루트에서 다음 명령으로 환경 변수를 전달합니다.
|
||||
```bash
|
||||
flutter test integration_test/stock_transaction_state_flow_test.dart \
|
||||
--dart-define=STAGING_RUN_TRANSACTION_FLOW=true \
|
||||
--dart-define=STAGING_API_BASE_URL=https://staging.superport.example \
|
||||
--dart-define=STAGING_API_TOKEN=<token> \
|
||||
--dart-define=STAGING_TRANSACTION_TYPE_ID=1 \
|
||||
--dart-define=STAGING_TRANSACTION_STATUS_ID=1 \
|
||||
--dart-define=STAGING_WAREHOUSE_ID=3 \
|
||||
--dart-define=STAGING_EMPLOYEE_ID=5 \
|
||||
--dart-define=STAGING_PRODUCT_ID=11 \
|
||||
--dart-define=STAGING_CUSTOMER_ID=7
|
||||
```
|
||||
2. 테스트는 다음 흐름을 검증합니다.
|
||||
- 재고 트랜잭션 생성 → 상신(submit) → 취소(cancel) → 삭제(delete)
|
||||
- 실패 시 HTTP 응답을 출력하므로, 로그를 토대로 API 상태를 점검합니다.
|
||||
|
||||
## 수동 검증 체크리스트
|
||||
1. **생성**: 입고/출고/대여 중 하나를 생성하고, 작성자·창고·라인·고객 정보가 정확히 저장되는지 확인합니다.
|
||||
2. **상태 전이**: 작성중 → 상신 → 승인/완료 → 취소 순으로 버튼이 활성화되는지와 API 응답 코드를 확인합니다.
|
||||
3. **보고서 다운로드**: 동일한 조건으로 보고서를 다운로드하여 URL/파일이 정상 동작하는지 브라우저에서 재확인합니다.
|
||||
4. **정리**: 테스트용 데이터는 취소 후 삭제하여 스테이징 목록을 정리합니다.
|
||||
|
||||
## 장애 대응 메모
|
||||
- 4xx 발생 시 API 로그와 `feature_flag` 값을 우선 확인합니다.
|
||||
- 5xx 또는 타임아웃은 백엔드 담당자에게 API 경로와 요청 페이로드를 공유합니다.
|
||||
344
integration_test/stock_transaction_state_flow_test.dart
Normal file
344
integration_test/stock_transaction_state_flow_test.dart
Normal file
@@ -0,0 +1,344 @@
|
||||
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();
|
||||
|
||||
final createInput = StockTransactionCreateInput(
|
||||
transactionTypeId: resolvedTransactionTypeId,
|
||||
transactionStatusId: resolvedTransactionStatusId,
|
||||
warehouseId: resolvedWarehouseId,
|
||||
transactionDate: now,
|
||||
createdById: resolvedEmployeeId,
|
||||
note: 'integration-test ${now.toIso8601String()}',
|
||||
lines: [
|
||||
TransactionLineCreateInput(
|
||||
lineNo: 1,
|
||||
productId: resolvedProductId,
|
||||
quantity: 1,
|
||||
unitPrice: 1,
|
||||
),
|
||||
],
|
||||
customers: [
|
||||
TransactionCustomerCreateInput(customerId: resolvedCustomerId),
|
||||
],
|
||||
);
|
||||
|
||||
final created = await repository.create(createInput);
|
||||
expect(created.id, isNotNull);
|
||||
tester.printToConsole('created transaction: ${created.id}');
|
||||
|
||||
// 상태 전이: submit -> cancel 순으로 흐름 검증 (승인 루프는 환경에 따라 조정 필요).
|
||||
final submitted = await repository.submit(created.id!);
|
||||
expect(submitted.id, equals(created.id));
|
||||
tester.printToConsole('submitted transaction: ${submitted.id}');
|
||||
|
||||
final cancelled = await repository.cancel(created.id!);
|
||||
expect(cancelled.id, equals(created.id));
|
||||
tester.printToConsole('cancelled transaction: ${cancelled.id}');
|
||||
|
||||
// 테스트 데이터 정리.
|
||||
await repository.delete(created.id!);
|
||||
tester.printToConsole('deleted transaction: ${created.id}');
|
||||
});
|
||||
}
|
||||
|
||||
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 transaction = StockTransaction(
|
||||
id: id,
|
||||
transactionNo:
|
||||
input.transactionNo ?? 'FAKE-${id.toString().padLeft(6, '0')}',
|
||||
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) async {
|
||||
return _updateStatus(
|
||||
id,
|
||||
StockTransactionStatus(id: initialStatusId + 1, name: '승인대기'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> complete(int id) async {
|
||||
return _updateStatus(
|
||||
id,
|
||||
StockTransactionStatus(id: initialStatusId + 2, name: '완료'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> approve(int id) async {
|
||||
return _updateStatus(
|
||||
id,
|
||||
StockTransactionStatus(id: initialStatusId + 3, name: '승인완료'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> reject(int id) async {
|
||||
return _updateStatus(
|
||||
id,
|
||||
StockTransactionStatus(id: initialStatusId + 4, name: '반려'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> cancel(int id) 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;
|
||||
}
|
||||
}
|
||||
8
lib/core/network/api_routes.dart
Normal file
8
lib/core/network/api_routes.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
/// API 경로 상수 모음
|
||||
/// - 버전 prefix 등을 중앙에서 관리해 중복을 방지한다.
|
||||
class ApiRoutes {
|
||||
const ApiRoutes._();
|
||||
|
||||
/// API v1 prefix
|
||||
static const apiV1 = '/api/v1';
|
||||
}
|
||||
116
lib/core/permissions/permission_resources.dart
Normal file
116
lib/core/permissions/permission_resources.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
/// 권한 검사에 사용하는 리소스 경로 상수를 정의하고 정규화 유틸을 제공한다.
|
||||
///
|
||||
/// - UI 라우트 경로(`/inventory/inbound` 등)를 서버 표준 경로(`/stock-transactions`)
|
||||
/// 로 변환해 [PermissionManager]와 환경 설정이 동일한 키를 사용하도록 맞춘다.
|
||||
class PermissionResources {
|
||||
const PermissionResources._();
|
||||
|
||||
static const String dashboard = '/dashboard';
|
||||
static const String stockTransactions = '/stock-transactions';
|
||||
static const String approvals = '/approvals';
|
||||
static const String approvalSteps = '/approval-steps';
|
||||
static const String approvalHistories = '/approval-histories';
|
||||
static const String approvalTemplates = '/approval-templates';
|
||||
static const String groupMenuPermissions = '/group-menu-permissions';
|
||||
static const String vendors = '/vendors';
|
||||
static const String products = '/products';
|
||||
static const String warehouses = '/warehouses';
|
||||
static const String customers = '/customers';
|
||||
static const String users = '/users';
|
||||
static const String groups = '/groups';
|
||||
static const String menus = '/menus';
|
||||
static const String postalSearch = '/zipcodes';
|
||||
static const String reports = '/reports';
|
||||
static const String reportsTransactions = '/reports/transactions';
|
||||
static const String reportsApprovals = '/reports/approvals';
|
||||
|
||||
/// 라우트/엔드포인트 별칭을 표준 경로로 매핑한 테이블.
|
||||
static const Map<String, String> _aliases = {
|
||||
'/dashboard': dashboard,
|
||||
'/inventory': stockTransactions,
|
||||
'/inventory/inbound': stockTransactions,
|
||||
'/inventory/outbound': stockTransactions,
|
||||
'/inventory/rental': stockTransactions,
|
||||
'/approvals/requests': approvals,
|
||||
'/approvals': approvals,
|
||||
'/approvals/steps': approvalSteps,
|
||||
'/approval-steps': approvalSteps,
|
||||
'/approvals/history': approvalHistories,
|
||||
'/approvals/histories': approvalHistories,
|
||||
'/approval-histories': approvalHistories,
|
||||
'/approvals/templates': approvalTemplates,
|
||||
'/approval-templates': approvalTemplates,
|
||||
'/masters/group-permissions': groupMenuPermissions,
|
||||
'/group-menu-permissions': groupMenuPermissions,
|
||||
'/masters/vendors': vendors,
|
||||
'/vendors': vendors,
|
||||
'/masters/products': products,
|
||||
'/products': products,
|
||||
'/masters/warehouses': warehouses,
|
||||
'/warehouses': warehouses,
|
||||
'/masters/customers': customers,
|
||||
'/customers': customers,
|
||||
'/masters/users': users,
|
||||
'/users': users,
|
||||
'/masters/groups': groups,
|
||||
'/groups': groups,
|
||||
'/masters/menus': menus,
|
||||
'/menus': menus,
|
||||
'/utilities/postal-search': postalSearch,
|
||||
'/zipcodes': postalSearch,
|
||||
'/reports': reports,
|
||||
'/reports/transactions': reportsTransactions,
|
||||
'/reports/transactions/export': reportsTransactions,
|
||||
'/reports/approvals': reportsApprovals,
|
||||
'/reports/approvals/export': reportsApprovals,
|
||||
};
|
||||
|
||||
/// 주어진 [resource] 문자열을 서버 표준 경로로 정규화한다.
|
||||
///
|
||||
/// - 앞뒤 공백 및 대소문자를 정리한다.
|
||||
/// - 맵에 등록된 라우트/별칭을 모두 표준 경로로 통일한다.
|
||||
static String normalize(String resource) {
|
||||
final sanitized = _sanitize(resource);
|
||||
if (sanitized.isEmpty) {
|
||||
return sanitized;
|
||||
}
|
||||
return _aliases[sanitized] ?? sanitized;
|
||||
}
|
||||
|
||||
static String _sanitize(String resource) {
|
||||
final trimmed = resource.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
var lowered = trimmed.toLowerCase();
|
||||
|
||||
// 절대 URL이 들어오면 path 부분만 추출한다.
|
||||
final uri = Uri.tryParse(lowered);
|
||||
if (uri != null && uri.hasScheme) {
|
||||
lowered = uri.path;
|
||||
}
|
||||
|
||||
// 쿼리스트링이나 프래그먼트를 제거해 순수 경로만 남긴다.
|
||||
final queryIndex = lowered.indexOf('?');
|
||||
if (queryIndex != -1) {
|
||||
lowered = lowered.substring(0, queryIndex);
|
||||
}
|
||||
final hashIndex = lowered.indexOf('#');
|
||||
if (hashIndex != -1) {
|
||||
lowered = lowered.substring(0, hashIndex);
|
||||
}
|
||||
|
||||
if (!lowered.startsWith('/')) {
|
||||
lowered = '/$lowered';
|
||||
}
|
||||
|
||||
while (lowered.contains('//')) {
|
||||
lowered = lowered.replaceAll('//', '/');
|
||||
}
|
||||
|
||||
if (lowered.length > 1 && lowered.endsWith('/')) {
|
||||
lowered = lowered.substring(0, lowered.length - 1);
|
||||
}
|
||||
return lowered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'package:superport_v2/core/network/api_client.dart';
|
||||
import 'package:superport_v2/core/network/api_routes.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';
|
||||
|
||||
/// 보고서 다운로드 API를 호출하는 원격 리포지토리 구현체.
|
||||
class ReportingRepositoryRemote implements ReportingRepository {
|
||||
ReportingRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
static const _transactionsPath =
|
||||
'${ApiRoutes.apiV1}/reports/transactions/export';
|
||||
static const _approvalsPath = '${ApiRoutes.apiV1}/reports/approvals/export';
|
||||
|
||||
@override
|
||||
Future<ReportDownloadResult> exportTransactions(
|
||||
ReportExportRequest request,
|
||||
) async {
|
||||
final response = await _api.get<Uint8List>(
|
||||
_transactionsPath,
|
||||
query: _buildQuery(request),
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return _mapResponse(response, format: request.format);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ReportDownloadResult> exportApprovals(
|
||||
ReportExportRequest request,
|
||||
) async {
|
||||
final response = await _api.get<Uint8List>(
|
||||
_approvalsPath,
|
||||
query: _buildQuery(request),
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return _mapResponse(response, format: request.format);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildQuery(ReportExportRequest request) {
|
||||
return {
|
||||
'from': request.from.toIso8601String(),
|
||||
'to': request.to.toIso8601String(),
|
||||
'format': request.format.apiValue,
|
||||
if (request.transactionTypeId != null)
|
||||
'type_id': request.transactionTypeId,
|
||||
if (request.statusId != null) 'status_id': request.statusId,
|
||||
if (request.warehouseId != null) 'warehouse_id': request.warehouseId,
|
||||
};
|
||||
}
|
||||
|
||||
ReportDownloadResult _mapResponse(
|
||||
Response<Uint8List> response, {
|
||||
required ReportExportFormat format,
|
||||
}) {
|
||||
final contentType = response.headers.value('content-type') ?? '';
|
||||
final disposition = response.headers.value('content-disposition') ?? '';
|
||||
|
||||
if (contentType.contains('application/json')) {
|
||||
final body = _decodeJson(response.data);
|
||||
final map = body is Map<String, dynamic> ? body : <String, dynamic>{};
|
||||
final data = map['data'] is Map<String, dynamic>
|
||||
? map['data'] as Map<String, dynamic>
|
||||
: map;
|
||||
final url = _readString(data, 'download_url');
|
||||
return ReportDownloadResult(
|
||||
downloadUrl: url == null ? null : Uri.tryParse(url),
|
||||
filename: _readString(data, 'filename'),
|
||||
mimeType: _readString(data, 'mime_type') ?? _defaultMimeType(format),
|
||||
expiresAt: _parseDateTime(_readString(data, 'expires_at')),
|
||||
);
|
||||
}
|
||||
|
||||
final filename = _parseFilename(disposition) ?? 'report.${format.apiValue}';
|
||||
final bytes = response.data ?? Uint8List(0);
|
||||
|
||||
return ReportDownloadResult(
|
||||
bytes: bytes,
|
||||
filename: filename,
|
||||
mimeType: contentType.isEmpty ? _defaultMimeType(format) : contentType,
|
||||
);
|
||||
}
|
||||
|
||||
dynamic _decodeJson(Uint8List? bytes) {
|
||||
if (bytes == null || bytes.isEmpty) {
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
try {
|
||||
final decoded = utf8.decode(bytes);
|
||||
return jsonDecode(decoded);
|
||||
} catch (_) {
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
|
||||
String? _readString(Map<String, dynamic> map, String key) {
|
||||
final value = map[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? _parseDateTime(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
|
||||
String? _parseFilename(String disposition) {
|
||||
if (disposition.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final parts = disposition.split(';');
|
||||
for (final part in parts) {
|
||||
final trimmed = part.trim();
|
||||
if (trimmed.toLowerCase().startsWith('filename=')) {
|
||||
final encoded = trimmed.substring('filename='.length);
|
||||
return encoded.replaceAll('"', '');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _defaultMimeType(ReportExportFormat format) {
|
||||
return switch (format) {
|
||||
ReportExportFormat.xlsx =>
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ReportExportFormat.pdf => 'application/pdf',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// 보고서 다운로드 요청 결과 모델.
|
||||
class ReportDownloadResult {
|
||||
const ReportDownloadResult({
|
||||
this.downloadUrl,
|
||||
this.filename,
|
||||
this.mimeType,
|
||||
this.bytes,
|
||||
this.expiresAt,
|
||||
});
|
||||
|
||||
/// 사전 서명된 다운로드 URL.
|
||||
final Uri? downloadUrl;
|
||||
|
||||
/// 서버가 제안한 파일명.
|
||||
final String? filename;
|
||||
|
||||
/// 응답 콘텐츠 타입.
|
||||
final String? mimeType;
|
||||
|
||||
/// 서버가 직접 전송한 파일 데이터(옵션).
|
||||
final Uint8List? bytes;
|
||||
|
||||
/// 다운로드 링크 또는 토큰의 만료 시각.
|
||||
final DateTime? expiresAt;
|
||||
|
||||
/// URL 기반 다운로드가 가능한지 여부.
|
||||
bool get hasDownloadUrl => downloadUrl != null;
|
||||
|
||||
/// 바이너리 데이터가 포함되어 있는지 여부.
|
||||
bool get hasBytes => bytes != null && bytes!.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/// 보고서 내보내기 파일 형식.
|
||||
enum ReportExportFormat { xlsx, pdf }
|
||||
|
||||
extension ReportExportFormatX on ReportExportFormat {
|
||||
/// API 요청에 전달되는 문자열 포맷.
|
||||
String get apiValue => switch (this) {
|
||||
ReportExportFormat.xlsx => 'xlsx',
|
||||
ReportExportFormat.pdf => 'pdf',
|
||||
};
|
||||
|
||||
/// 사용자에게 노출되는 레이블.
|
||||
String get label => switch (this) {
|
||||
ReportExportFormat.xlsx => 'XLSX',
|
||||
ReportExportFormat.pdf => 'PDF',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:superport_v2/features/reporting/domain/entities/report_export_format.dart';
|
||||
|
||||
/// 보고서 내보내기 요청을 표현하는 모델.
|
||||
class ReportExportRequest {
|
||||
const ReportExportRequest({
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.format,
|
||||
this.transactionTypeId,
|
||||
this.statusId,
|
||||
this.warehouseId,
|
||||
});
|
||||
|
||||
/// 조회 시작 일자.
|
||||
final DateTime from;
|
||||
|
||||
/// 조회 종료 일자.
|
||||
final DateTime to;
|
||||
|
||||
/// 내보내기 파일 형식.
|
||||
final ReportExportFormat format;
|
||||
|
||||
/// 재고 트랜잭션 유형 식별자.
|
||||
final int? transactionTypeId;
|
||||
|
||||
/// 결재 상태 식별자.
|
||||
final int? statusId;
|
||||
|
||||
/// 창고 식별자.
|
||||
final int? warehouseId;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:superport_v2/features/reporting/domain/entities/report_download_result.dart';
|
||||
import 'package:superport_v2/features/reporting/domain/entities/report_export_request.dart';
|
||||
|
||||
/// 보고서 다운로드 관련 리포지토리 계약.
|
||||
abstract class ReportingRepository {
|
||||
/// 재고 트랜잭션 보고서를 내보낸다.
|
||||
Future<ReportDownloadResult> exportTransactions(ReportExportRequest request);
|
||||
|
||||
/// 결재 보고서를 내보낸다.
|
||||
Future<ReportDownloadResult> exportApprovals(ReportExportRequest request);
|
||||
}
|
||||
@@ -7,6 +7,12 @@ import 'core/network/api_client.dart';
|
||||
import 'core/network/api_error.dart';
|
||||
import 'core/network/interceptors/auth_interceptor.dart';
|
||||
import 'core/services/token_storage.dart';
|
||||
import 'features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart';
|
||||
import 'features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||
import 'features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart';
|
||||
import 'features/inventory/transactions/data/repositories/transaction_customer_repository_remote.dart';
|
||||
import 'features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart';
|
||||
import 'features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
|
||||
import 'features/masters/customer/data/repositories/customer_repository_remote.dart';
|
||||
import 'features/masters/customer/domain/repositories/customer_repository.dart';
|
||||
import 'features/masters/group/data/repositories/group_repository_remote.dart';
|
||||
@@ -35,6 +41,8 @@ import 'features/approvals/domain/repositories/approval_template_repository.dart
|
||||
import 'features/approvals/step/domain/repositories/approval_step_repository.dart';
|
||||
import 'features/util/postal_search/data/repositories/postal_search_repository_remote.dart';
|
||||
import 'features/util/postal_search/domain/repositories/postal_search_repository.dart';
|
||||
import 'features/reporting/data/repositories/reporting_repository_remote.dart';
|
||||
import 'features/reporting/domain/repositories/reporting_repository.dart';
|
||||
|
||||
/// 전역 DI 컨테이너
|
||||
final GetIt sl = GetIt.instance;
|
||||
@@ -128,4 +136,24 @@ Future<void> initInjection({
|
||||
sl.registerLazySingleton<PostalSearchRepository>(
|
||||
() => PostalSearchRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||
);
|
||||
|
||||
sl.registerLazySingleton<InventoryLookupRepository>(
|
||||
() => InventoryLookupRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||
);
|
||||
|
||||
sl.registerLazySingleton<StockTransactionRepository>(
|
||||
() => StockTransactionRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||
);
|
||||
|
||||
sl.registerLazySingleton<TransactionLineRepository>(
|
||||
() => TransactionLineRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||
);
|
||||
|
||||
sl.registerLazySingleton<TransactionCustomerRepository>(
|
||||
() => TransactionCustomerRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||
);
|
||||
|
||||
sl.registerLazySingleton<ReportingRepository>(
|
||||
() => ReportingRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,13 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -7,8 +7,10 @@ import Foundation
|
||||
|
||||
import flutter_secure_storage_macos
|
||||
import path_provider_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
35
macos/Podfile.lock
Normal file
35
macos/Podfile.lock
Normal file
@@ -0,0 +1,35 @@
|
||||
PODS:
|
||||
- flutter_secure_storage_macos (6.1.3):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
flutter_secure_storage_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
path_provider_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
||||
url_launcher_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
flutter_secure_storage_macos: c2754d3483d20bb207bb9e5a14f1b8e771abcdb9
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
|
||||
|
||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -27,6 +27,8 @@
|
||||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||
37F758093A1AEA5BAF00D85B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A32A030F236AD9E49D10327 /* Pods_Runner.framework */; };
|
||||
7F56BCAE27A8EA167C62800B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0BBED327CEE1D6DD142492EF /* Pods_RunnerTests.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -60,11 +62,12 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0BBED327CEE1D6DD142492EF /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||
33CC10ED2044A3C60003C045 /* superport_v2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "superport_v2.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10ED2044A3C60003C045 /* superport_v2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = superport_v2.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||
@@ -76,8 +79,15 @@
|
||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
||||
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
||||
49B67878BF0F3BF4B151E4B6 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
5A32A030F236AD9E49D10327 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
71D1BE2DCCBC4CD5B4A77168 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||
B2EA1058A08FF313CABCAAFD /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
DB7F01D88D151324C95DADE7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
F727904914C33F5A486241E6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
FD86D9C1321338144FC65054 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -85,6 +95,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7F56BCAE27A8EA167C62800B /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -92,6 +103,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
37F758093A1AEA5BAF00D85B /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -125,6 +137,7 @@
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */,
|
||||
33CC10EE2044A3C60003C045 /* Products */,
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||
FAF81764370EF4E216256F29 /* Pods */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -175,10 +188,26 @@
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5A32A030F236AD9E49D10327 /* Pods_Runner.framework */,
|
||||
0BBED327CEE1D6DD142492EF /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FAF81764370EF4E216256F29 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FD86D9C1321338144FC65054 /* Pods-Runner.debug.xcconfig */,
|
||||
B2EA1058A08FF313CABCAAFD /* Pods-Runner.release.xcconfig */,
|
||||
F727904914C33F5A486241E6 /* Pods-Runner.profile.xcconfig */,
|
||||
49B67878BF0F3BF4B151E4B6 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
DB7F01D88D151324C95DADE7 /* Pods-RunnerTests.release.xcconfig */,
|
||||
71D1BE2DCCBC4CD5B4A77168 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -186,6 +215,7 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
039842304FE99306B814507F /* [CP] Check Pods Manifest.lock */,
|
||||
331C80D1294CF70F00263BE5 /* Sources */,
|
||||
331C80D2294CF70F00263BE5 /* Frameworks */,
|
||||
331C80D3294CF70F00263BE5 /* Resources */,
|
||||
@@ -204,11 +234,13 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
645522EC9C304EC96AC71CCD /* [CP] Check Pods Manifest.lock */,
|
||||
33CC10E92044A3C60003C045 /* Sources */,
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||
33CC10EB2044A3C60003C045 /* Resources */,
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||
69C58EF3926BE525BD73F111 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -291,6 +323,28 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
039842304FE99306B814507F /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
3399D490228B24CF009A79C7 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -329,6 +383,45 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
||||
};
|
||||
645522EC9C304EC96AC71CCD /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
69C58EF3926BE525BD73F111 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -380,6 +473,7 @@
|
||||
/* Begin XCBuildConfiguration section */
|
||||
331C80DB294CF71000263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 49B67878BF0F3BF4B151E4B6 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -394,6 +488,7 @@
|
||||
};
|
||||
331C80DC294CF71000263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = DB7F01D88D151324C95DADE7 /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -408,6 +503,7 @@
|
||||
};
|
||||
331C80DD294CF71000263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 71D1BE2DCCBC4CD5B4A77168 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
111
pubspec.lock
111
pubspec.lock
@@ -121,6 +121,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -142,6 +150,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.1"
|
||||
flutter_driver:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -229,6 +242,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
fuchsia_remote_debug_protocol:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
get_it:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -269,6 +287,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -469,6 +492,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.5"
|
||||
shadcn_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -514,6 +545,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
sync_http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sync_http
|
||||
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -554,6 +593,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.11"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.24"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.4"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.3"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -602,6 +705,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webdriver
|
||||
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -45,11 +45,14 @@ dependencies:
|
||||
get_it: ^7.7.0
|
||||
flutter_dotenv: ^5.1.0
|
||||
flutter_secure_storage: ^9.2.2
|
||||
url_launcher: ^6.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
pretty_dio_logger: ^1.3.1
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
|
||||
97
test/core/network/api_client_test.dart
Normal file
97
test/core/network/api_client_test.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
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/core/network/api_error.dart';
|
||||
|
||||
class _MockDio extends Mock implements Dio {}
|
||||
|
||||
class _MockApiErrorMapper extends Mock implements ApiErrorMapper {}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
registerFallbackValue(RequestOptions(path: '/fallback'));
|
||||
registerFallbackValue(Options());
|
||||
registerFallbackValue(CancelToken());
|
||||
});
|
||||
|
||||
group('ApiClient', () {
|
||||
late Dio dio;
|
||||
late ApiErrorMapper mapper;
|
||||
late ApiClient client;
|
||||
|
||||
setUp(() {
|
||||
dio = _MockDio();
|
||||
mapper = _MockApiErrorMapper();
|
||||
client = ApiClient(dio: dio, errorMapper: mapper);
|
||||
});
|
||||
|
||||
test('성공 응답을 반환한다', () async {
|
||||
final requestOptions = RequestOptions(path: '/vendors');
|
||||
final response = Response<Map<String, dynamic>>(
|
||||
data: {'data': []},
|
||||
statusCode: 200,
|
||||
requestOptions: requestOptions,
|
||||
);
|
||||
|
||||
when(
|
||||
() => dio.get<Map<String, dynamic>>(
|
||||
any(),
|
||||
queryParameters: any(named: 'queryParameters'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).thenAnswer((_) async => response);
|
||||
|
||||
final result = await client.get<Map<String, dynamic>>('/vendors');
|
||||
|
||||
expect(result, same(response));
|
||||
verify(
|
||||
() => dio.get<Map<String, dynamic>>(
|
||||
any(),
|
||||
queryParameters: any(named: 'queryParameters'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('DioException을 ApiException으로 매핑한다', () async {
|
||||
final requestOptions = RequestOptions(path: '/vendors');
|
||||
final dioError = DioException(
|
||||
requestOptions: requestOptions,
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response<dynamic>(
|
||||
requestOptions: requestOptions,
|
||||
statusCode: 400,
|
||||
data: {'message': '잘못된 요청입니다.'},
|
||||
),
|
||||
);
|
||||
final expected = ApiException(
|
||||
code: ApiErrorCode.badRequest,
|
||||
message: '잘못된 요청입니다.',
|
||||
statusCode: 400,
|
||||
cause: dioError,
|
||||
);
|
||||
|
||||
when(
|
||||
() => dio.get<Map<String, dynamic>>(
|
||||
any(),
|
||||
queryParameters: any(named: 'queryParameters'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).thenThrow(dioError);
|
||||
|
||||
when(() => mapper.map(dioError)).thenReturn(expected);
|
||||
|
||||
expect(
|
||||
() => client.get<Map<String, dynamic>>('/vendors'),
|
||||
throwsA(same(expected)),
|
||||
);
|
||||
|
||||
verify(() => mapper.map(dioError)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
122
test/core/network/auth_interceptor_test.dart
Normal file
122
test/core/network/auth_interceptor_test.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:superport_v2/core/network/interceptors/auth_interceptor.dart';
|
||||
import 'package:superport_v2/core/services/token_storage.dart';
|
||||
|
||||
class _MockTokenStorage extends Mock implements TokenStorage {}
|
||||
|
||||
class _MockDio extends Mock implements Dio {}
|
||||
|
||||
class _CapturingErrorHandler extends ErrorInterceptorHandler {
|
||||
Response<dynamic>? resolved;
|
||||
DioException? forwarded;
|
||||
|
||||
@override
|
||||
void resolve(Response<dynamic> response) {
|
||||
resolved = response;
|
||||
}
|
||||
|
||||
@override
|
||||
void next(DioException err) {
|
||||
forwarded = err;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
registerFallbackValue(RequestOptions(path: '/fallback'));
|
||||
});
|
||||
|
||||
group('AuthInterceptor', () {
|
||||
late TokenStorage storage;
|
||||
late Dio dio;
|
||||
|
||||
setUp(() {
|
||||
storage = _MockTokenStorage();
|
||||
dio = _MockDio();
|
||||
when(() => storage.writeAccessToken(any())).thenAnswer((_) async {});
|
||||
when(() => storage.writeRefreshToken(any())).thenAnswer((_) async {});
|
||||
when(() => storage.clear()).thenAnswer((_) async {});
|
||||
});
|
||||
|
||||
test('401 응답 시 토큰을 갱신하고 요청을 재시도한다', () async {
|
||||
when(
|
||||
() => storage.readAccessToken(),
|
||||
).thenAnswer((_) async => 'renewed-access');
|
||||
when(() => dio.fetch<dynamic>(any())).thenAnswer((invocation) async {
|
||||
final options = invocation.positionalArguments.first as RequestOptions;
|
||||
return Response<dynamic>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: {'ok': true},
|
||||
);
|
||||
});
|
||||
|
||||
final interceptor = AuthInterceptor(
|
||||
tokenStorage: storage,
|
||||
dio: dio,
|
||||
onRefresh: () async => const TokenPair(
|
||||
accessToken: 'renewed-access',
|
||||
refreshToken: 'renewed-refresh',
|
||||
),
|
||||
);
|
||||
|
||||
final requestOptions = RequestOptions(path: '/approvals');
|
||||
requestOptions.headers['Authorization'] = 'Bearer legacy-token';
|
||||
final error = DioException(
|
||||
requestOptions: requestOptions,
|
||||
response: Response<dynamic>(
|
||||
requestOptions: requestOptions,
|
||||
statusCode: 401,
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
final handler = _CapturingErrorHandler();
|
||||
await interceptor.onError(error, handler);
|
||||
|
||||
expect(handler.forwarded, isNull);
|
||||
expect(handler.resolved, isNotNull);
|
||||
expect(
|
||||
requestOptions.headers['Authorization'],
|
||||
equals('Bearer renewed-access'),
|
||||
);
|
||||
verify(() => storage.writeAccessToken('renewed-access')).called(1);
|
||||
verify(() => storage.writeRefreshToken('renewed-refresh')).called(1);
|
||||
verifyNever(() => storage.clear());
|
||||
verify(() => dio.fetch<dynamic>(any())).called(1);
|
||||
});
|
||||
|
||||
test('토큰 갱신 실패 시 저장소를 초기화하고 오류를 전달한다', () async {
|
||||
when(
|
||||
() => storage.readAccessToken(),
|
||||
).thenAnswer((_) async => 'legacy-token');
|
||||
|
||||
final interceptor = AuthInterceptor(
|
||||
tokenStorage: storage,
|
||||
dio: dio,
|
||||
onRefresh: () async => null,
|
||||
);
|
||||
|
||||
final requestOptions = RequestOptions(path: '/approvals');
|
||||
final error = DioException(
|
||||
requestOptions: requestOptions,
|
||||
response: Response<dynamic>(
|
||||
requestOptions: requestOptions,
|
||||
statusCode: 401,
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
final handler = _CapturingErrorHandler();
|
||||
await interceptor.onError(error, handler);
|
||||
|
||||
expect(handler.resolved, isNull);
|
||||
expect(handler.forwarded, same(error));
|
||||
verify(() => storage.clear()).called(1);
|
||||
verifyNever(() => dio.fetch<dynamic>(any()));
|
||||
});
|
||||
});
|
||||
}
|
||||
190
test/features/login/presentation/pages/login_page_test.dart
Normal file
190
test/features/login/presentation/pages/login_page_test.dart
Normal file
@@ -0,0 +1,190 @@
|
||||
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:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||
import 'package:superport_v2/core/permissions/permission_resources.dart';
|
||||
import 'package:superport_v2/features/login/presentation/pages/login_page.dart';
|
||||
import 'package:superport_v2/features/masters/group/domain/entities/group.dart';
|
||||
import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart';
|
||||
import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart';
|
||||
import 'package:superport_v2/features/masters/group_permission/domain/repositories/group_permission_repository.dart';
|
||||
|
||||
class _MockGroupRepository extends Mock implements GroupRepository {}
|
||||
|
||||
class _MockGroupPermissionRepository extends Mock
|
||||
implements GroupPermissionRepository {}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
tearDown(() async {
|
||||
await GetIt.I.reset();
|
||||
});
|
||||
|
||||
testWidgets('로그인 성공 시 권한 동기화 후 대시보드로 이동한다', (tester) async {
|
||||
final groupRepository = _MockGroupRepository();
|
||||
final permissionRepository = _MockGroupPermissionRepository();
|
||||
GetIt.I.registerSingleton<GroupRepository>(groupRepository);
|
||||
GetIt.I.registerSingleton<GroupPermissionRepository>(permissionRepository);
|
||||
|
||||
when(
|
||||
() => groupRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
isDefault: any(named: 'isDefault'),
|
||||
isActive: any(named: 'isActive'),
|
||||
includePermissions: any(named: 'includePermissions'),
|
||||
includeEmployees: any(named: 'includeEmployees'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
final items = [Group(id: 1, groupName: '관리자')];
|
||||
return PaginatedResult<Group>(
|
||||
items: items,
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
total: 1,
|
||||
);
|
||||
});
|
||||
|
||||
when(
|
||||
() => permissionRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
groupId: any(named: 'groupId'),
|
||||
menuId: any(named: 'menuId'),
|
||||
isActive: any(named: 'isActive'),
|
||||
includeDeleted: any(named: 'includeDeleted'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => PaginatedResult<GroupPermission>(
|
||||
items: [
|
||||
GroupPermission(
|
||||
id: 1,
|
||||
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
|
||||
menu: GroupPermissionMenu(
|
||||
id: 10,
|
||||
menuCode: 'INBOUND',
|
||||
menuName: '입고',
|
||||
path: '/inventory/inbound',
|
||||
),
|
||||
canCreate: true,
|
||||
canRead: true,
|
||||
),
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
total: 1,
|
||||
),
|
||||
);
|
||||
|
||||
final manager = PermissionManager();
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: loginRoutePath,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: loginRoutePath,
|
||||
builder: (context, state) => const LoginPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: dashboardRoutePath,
|
||||
builder: (context, state) =>
|
||||
const Scaffold(body: Center(child: Text('대시보드'))),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
PermissionScope(
|
||||
manager: manager,
|
||||
child: ShadApp.router(routerConfig: router),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(EditableText).at(0), 'user@superport');
|
||||
await tester.enterText(find.byType(EditableText).at(1), 'password');
|
||||
await tester.tap(find.text('로그인'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
router.routerDelegate.currentConfiguration.last.matchedLocation,
|
||||
dashboardRoutePath,
|
||||
);
|
||||
expect(
|
||||
manager.can(PermissionResources.stockTransactions, PermissionAction.view),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.create,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('권한 동기화 실패 시 오류 메시지를 표시한다', (tester) async {
|
||||
final groupRepository = _MockGroupRepository();
|
||||
final permissionRepository = _MockGroupPermissionRepository();
|
||||
GetIt.I.registerSingleton<GroupRepository>(groupRepository);
|
||||
GetIt.I.registerSingleton<GroupPermissionRepository>(permissionRepository);
|
||||
|
||||
when(
|
||||
() => groupRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
isDefault: any(named: 'isDefault'),
|
||||
isActive: any(named: 'isActive'),
|
||||
includePermissions: any(named: 'includePermissions'),
|
||||
includeEmployees: any(named: 'includeEmployees'),
|
||||
),
|
||||
).thenThrow(Exception('network error'));
|
||||
|
||||
final manager = PermissionManager();
|
||||
final router = GoRouter(
|
||||
initialLocation: loginRoutePath,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: loginRoutePath,
|
||||
builder: (context, state) => const LoginPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: dashboardRoutePath,
|
||||
builder: (context, state) =>
|
||||
const Scaffold(body: Center(child: Text('대시보드'))),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
PermissionScope(
|
||||
manager: manager,
|
||||
child: ShadApp.router(routerConfig: router),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(EditableText).at(0), 'user@superport');
|
||||
await tester.enterText(find.byType(EditableText).at(1), 'password');
|
||||
await tester.tap(find.text('로그인'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.'), findsOneWidget);
|
||||
expect(
|
||||
router.routerDelegate.currentConfiguration.last.matchedLocation,
|
||||
loginRoutePath,
|
||||
);
|
||||
expect(
|
||||
manager.can(PermissionResources.stockTransactions, PermissionAction.view),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
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/reporting/data/repositories/reporting_repository_remote.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';
|
||||
|
||||
class _MockApiClient extends Mock implements ApiClient {}
|
||||
|
||||
void main() {
|
||||
late ApiClient apiClient;
|
||||
late ReportingRepositoryRemote repository;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(Options());
|
||||
registerFallbackValue(CancelToken());
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
apiClient = _MockApiClient();
|
||||
repository = ReportingRepositoryRemote(apiClient: apiClient);
|
||||
});
|
||||
|
||||
Response<Uint8List> jsonResponse(String path, Map<String, dynamic> body) {
|
||||
final bytes = Uint8List.fromList(jsonEncode(body).codeUnits);
|
||||
return Response<Uint8List>(
|
||||
data: bytes,
|
||||
headers: Headers.fromMap({
|
||||
'content-type': ['application/json'],
|
||||
}),
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 200,
|
||||
);
|
||||
}
|
||||
|
||||
Response<Uint8List> binaryResponse(
|
||||
String path, {
|
||||
required List<int> bytes,
|
||||
required String filename,
|
||||
required String mimeType,
|
||||
}) {
|
||||
return Response<Uint8List>(
|
||||
data: Uint8List.fromList(bytes),
|
||||
headers: Headers.fromMap({
|
||||
'content-type': [mimeType],
|
||||
'content-disposition': ['attachment; filename="$filename"'],
|
||||
}),
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 200,
|
||||
);
|
||||
}
|
||||
|
||||
test('exportTransactions는 download_url을 파싱한다', () async {
|
||||
const path = '/api/v1/reports/transactions/export';
|
||||
when(
|
||||
() => apiClient.get<Uint8List>(
|
||||
path,
|
||||
query: any(named: 'query'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => jsonResponse(path, {
|
||||
'data': {
|
||||
'download_url': 'https://example.com/report.xlsx',
|
||||
'filename': 'report.xlsx',
|
||||
'mime_type':
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'expires_at': '2025-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
final request = ReportExportRequest(
|
||||
from: DateTime(2024, 1, 1),
|
||||
to: DateTime(2024, 1, 31),
|
||||
format: ReportExportFormat.xlsx,
|
||||
transactionTypeId: 3,
|
||||
statusId: 1,
|
||||
warehouseId: 9,
|
||||
);
|
||||
|
||||
final result = await repository.exportTransactions(request);
|
||||
|
||||
final captured = verify(
|
||||
() => apiClient.get<Uint8List>(
|
||||
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['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(result.downloadUrl.toString(), 'https://example.com/report.xlsx');
|
||||
expect(result.filename, 'report.xlsx');
|
||||
expect(
|
||||
result.mimeType,
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
expect(result.expiresAt, DateTime.parse('2025-01-01T00:00:00Z'));
|
||||
expect(result.hasDownloadUrl, isTrue);
|
||||
expect(result.hasBytes, isFalse);
|
||||
});
|
||||
|
||||
test('exportApprovals는 바이너리 응답을 처리한다', () async {
|
||||
const path = '/api/v1/reports/approvals/export';
|
||||
when(
|
||||
() => apiClient.get<Uint8List>(
|
||||
path,
|
||||
query: any(named: 'query'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => binaryResponse(
|
||||
path,
|
||||
bytes: [1, 2, 3],
|
||||
filename: 'approval.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
),
|
||||
);
|
||||
|
||||
final request = ReportExportRequest(
|
||||
from: DateTime(2024, 2, 1),
|
||||
to: DateTime(2024, 2, 15),
|
||||
format: ReportExportFormat.pdf,
|
||||
statusId: 5,
|
||||
);
|
||||
|
||||
final result = await repository.exportApprovals(request);
|
||||
|
||||
expect(result.hasBytes, isTrue);
|
||||
expect(result.bytes, isNotNull);
|
||||
expect(result.filename, 'approval.pdf');
|
||||
expect(result.mimeType, 'application/pdf');
|
||||
expect(result.hasDownloadUrl, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -7,8 +7,11 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Reference in New Issue
Block a user