chore: API 오류 매핑과 Failure 파서 고도화
This commit is contained in:
83
test/core/network/api_error_test.dart
Normal file
83
test/core/network/api_error_test.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:superport_v2/core/network/api_error.dart';
|
||||
|
||||
void main() {
|
||||
group('ApiErrorMapper', () {
|
||||
test('중첩 error 노드에서 메시지와 상세를 추출한다', () {
|
||||
final mapper = ApiErrorMapper();
|
||||
final requestOptions = RequestOptions(path: '/groups');
|
||||
final dioException = DioException(
|
||||
requestOptions: requestOptions,
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response<dynamic>(
|
||||
requestOptions: requestOptions,
|
||||
statusCode: 422,
|
||||
data: {
|
||||
'error': {
|
||||
'message': '그룹 이름이 이미 존재합니다.',
|
||||
'details': [
|
||||
{'field': 'group_name', 'message': '중복된 그룹 이름입니다.'},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final exception = mapper.map(dioException);
|
||||
|
||||
expect(exception.message, '그룹 이름이 이미 존재합니다.');
|
||||
expect(exception.statusCode, 422);
|
||||
expect(exception.details, isNotNull);
|
||||
final details = exception.details!['group_name'];
|
||||
if (details is Iterable) {
|
||||
expect(details, contains('중복된 그룹 이름입니다.'));
|
||||
} else {
|
||||
expect(details, '중복된 그룹 이름입니다.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('403 권한 오류 응답에서 세부 정보를 유지한다', () {
|
||||
final mapper = ApiErrorMapper();
|
||||
final requestOptions = RequestOptions(path: '/stock-transactions/1');
|
||||
final dioException = DioException(
|
||||
requestOptions: requestOptions,
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response<dynamic>(
|
||||
requestOptions: requestOptions,
|
||||
statusCode: 403,
|
||||
data: {
|
||||
'error': {
|
||||
'code': 'permission_denied',
|
||||
'message': '승인 권한이 없습니다.',
|
||||
'details': {'action': 'approve'},
|
||||
'context': {'resource': 'stock-transactions'},
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final exception = mapper.map(dioException);
|
||||
|
||||
expect(exception.code, ApiErrorCode.forbidden);
|
||||
expect(exception.message, '승인 권한이 없습니다.');
|
||||
final details = exception.details!;
|
||||
final action = details['action'];
|
||||
final resource = details['resource'];
|
||||
|
||||
Iterable<dynamic> asIterable(dynamic value) {
|
||||
if (value == null) {
|
||||
return const [];
|
||||
}
|
||||
if (value is Iterable) {
|
||||
return value;
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
|
||||
expect(asIterable(action), contains('approve'));
|
||||
expect(asIterable(resource), contains('stock-transactions'));
|
||||
});
|
||||
}
|
||||
107
test/core/network/failure_parser_test.dart
Normal file
107
test/core/network/failure_parser_test.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:superport_v2/core/network/api_error.dart';
|
||||
import 'package:superport_v2/core/network/failure.dart';
|
||||
|
||||
void main() {
|
||||
group('FailureParser', () {
|
||||
test('ApiException에서 메시지·필드 오류를 추출한다', () {
|
||||
final requestOptions = RequestOptions(path: '/test');
|
||||
final dioException = DioException(
|
||||
requestOptions: requestOptions,
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response<dynamic>(
|
||||
requestOptions: requestOptions,
|
||||
statusCode: 422,
|
||||
data: {
|
||||
'error': {
|
||||
'message': '입력값을 확인해 주세요.',
|
||||
'details': [
|
||||
{'field': 'quantity', 'message': '수량은 1 이상 입력해야 합니다.'},
|
||||
{'field': 'general', 'message': '재고 상태가 출고 불가 상태입니다.'},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final exception = ApiException(
|
||||
code: ApiErrorCode.unprocessableEntity,
|
||||
message: '요청 처리 중 오류가 발생했습니다.',
|
||||
statusCode: 422,
|
||||
cause: dioException,
|
||||
);
|
||||
|
||||
final failure = Failure.from(exception);
|
||||
|
||||
expect(failure.message, '입력값을 확인해 주세요.');
|
||||
expect(failure.fieldErrors['quantity'], isNotNull);
|
||||
expect(failure.fieldErrors['quantity'], contains('수량은 1 이상 입력해야 합니다.'));
|
||||
final description = failure.describe();
|
||||
expect(description, contains('입력값을 확인해 주세요.'));
|
||||
expect(description, contains('quantity: 수량은 1 이상 입력해야 합니다.'));
|
||||
expect(description, contains('재고 상태가 출고 불가 상태입니다.'));
|
||||
});
|
||||
|
||||
test('일반 Exception은 접두사를 제거한 메시지를 반환한다', () {
|
||||
final exception = Exception('네트워크 오류');
|
||||
|
||||
final failure = Failure.from(exception);
|
||||
|
||||
expect(failure.message, '네트워크 오류');
|
||||
expect(failure.describe(), '네트워크 오류');
|
||||
});
|
||||
|
||||
test('필드 오류만 존재해도 describe에 요약이 포함된다', () {
|
||||
final exception = ApiException(
|
||||
code: ApiErrorCode.badRequest,
|
||||
message: '잘못된 요청입니다.',
|
||||
details: {
|
||||
'approval_id': ['결재 ID를 입력하세요.'],
|
||||
'step_order': ['단계 순서를 입력하세요.'],
|
||||
},
|
||||
);
|
||||
|
||||
final failure = Failure.from(exception);
|
||||
|
||||
expect(failure.fieldErrors.keys, contains('approval_id'));
|
||||
final summary = failure.describe();
|
||||
expect(summary, contains('approval_id: 결재 ID를 입력하세요.'));
|
||||
expect(summary, contains('step_order: 단계 순서를 입력하세요.'));
|
||||
});
|
||||
});
|
||||
|
||||
test('권한 오류에서 코드·메시지·세부 정보를 추출한다', () {
|
||||
final requestOptions = RequestOptions(
|
||||
path: '/stock-transactions/42/approve',
|
||||
);
|
||||
final dioException = DioException(
|
||||
requestOptions: requestOptions,
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response<dynamic>(
|
||||
requestOptions: requestOptions,
|
||||
statusCode: 403,
|
||||
data: {
|
||||
'error': {
|
||||
'code': 'permission_denied',
|
||||
'message': '승인 권한이 없습니다.',
|
||||
'reasons': ['결재 관리자만 승인할 수 있습니다.'],
|
||||
'context': {'resource': 'stock-transactions', 'action': 'approve'},
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final failure = Failure.from(dioException);
|
||||
|
||||
expect(failure.code, ApiErrorCode.forbidden);
|
||||
expect(failure.message, '승인 권한이 없습니다.');
|
||||
expect(failure.reasons, contains('결재 관리자만 승인할 수 있습니다.'));
|
||||
expect(failure.fieldErrors['action'], isNotNull);
|
||||
expect(failure.fieldErrors['action'], contains('approve'));
|
||||
final description = failure.describe();
|
||||
expect(description, contains('승인 권한이 없습니다.'));
|
||||
expect(description, contains('action: approve'));
|
||||
});
|
||||
}
|
||||
@@ -1,73 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||
import 'package:superport_v2/core/permissions/permission_resources.dart';
|
||||
|
||||
void main() {
|
||||
group('PermissionManager', () {
|
||||
test('falls back to environment permissions when no override', () {
|
||||
test('서버 권한을 적용하면 해당 리소스 권한이 설정된다', () {
|
||||
final manager = PermissionManager();
|
||||
final allowed = manager.can('/any', PermissionAction.view);
|
||||
expect(allowed, isTrue);
|
||||
});
|
||||
|
||||
test('respects overrides', () {
|
||||
final manager = PermissionManager(
|
||||
overrides: {
|
||||
'/inventory/inbound': {
|
||||
PermissionAction.view,
|
||||
PermissionAction.create,
|
||||
},
|
||||
manager.applyServerPermissions({
|
||||
PermissionResources.stockTransactions: {
|
||||
PermissionAction.view,
|
||||
PermissionAction.create,
|
||||
},
|
||||
);
|
||||
expect(manager.can('/inventory/inbound', PermissionAction.view), isTrue);
|
||||
});
|
||||
|
||||
expect(
|
||||
manager.can('/inventory/inbound', PermissionAction.create),
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.view,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can('/inventory/inbound', PermissionAction.delete),
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.create,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.edit,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('오버라이드가 서버 권한보다 우선한다', () {
|
||||
final manager = PermissionManager();
|
||||
|
||||
manager.applyServerPermissions({
|
||||
PermissionResources.approvalSteps: {PermissionAction.view},
|
||||
});
|
||||
manager.updateOverrides({
|
||||
PermissionResources.approvalSteps: {PermissionAction.edit},
|
||||
});
|
||||
|
||||
expect(
|
||||
manager.can(PermissionResources.approvalSteps, PermissionAction.edit),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(PermissionResources.approvalSteps, PermissionAction.view),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('서버 권한을 초기화하면 환경 설정으로 되돌아간다', () {
|
||||
final manager = PermissionManager();
|
||||
manager.applyServerPermissions({
|
||||
PermissionResources.vendors: {PermissionAction.view},
|
||||
});
|
||||
expect(
|
||||
manager.can(PermissionResources.vendors, PermissionAction.edit),
|
||||
isFalse,
|
||||
);
|
||||
|
||||
manager.clearServerPermissions();
|
||||
|
||||
// 환경 설정에 권한이 명시되지 않으면 기본적으로 허용한다.
|
||||
expect(
|
||||
manager.can(PermissionResources.vendors, PermissionAction.edit),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('별칭 경로도 normalize되어 권한을 확인한다', () {
|
||||
final manager = PermissionManager();
|
||||
manager.applyServerPermissions({
|
||||
PermissionResources.stockTransactions: {PermissionAction.view},
|
||||
});
|
||||
|
||||
expect(
|
||||
manager.can('/Inventory/Inbound?page=1', PermissionAction.view),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can('/inventory/outbound', PermissionAction.edit),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('PermissionGate hides child when unauthorized', (tester) async {
|
||||
final manager = PermissionManager(overrides: {'/resource': {}});
|
||||
await tester.pumpWidget(
|
||||
PermissionScope(
|
||||
manager: manager,
|
||||
child: const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: PermissionGate(
|
||||
resource: '/resource',
|
||||
action: PermissionAction.view,
|
||||
child: Text('secret'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('secret'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('PermissionGate shows fallback when provided', (tester) async {
|
||||
final manager = PermissionManager(overrides: {'/resource': {}});
|
||||
await tester.pumpWidget(
|
||||
PermissionScope(
|
||||
manager: manager,
|
||||
child: const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: PermissionGate(
|
||||
resource: '/resource',
|
||||
action: PermissionAction.view,
|
||||
fallback: Text('fallback'),
|
||||
child: Text('secret'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('fallback'), findsOneWidget);
|
||||
expect(find.text('secret'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
48
test/core/permissions/permission_resources_test.dart
Normal file
48
test/core/permissions/permission_resources_test.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:superport_v2/core/permissions/permission_resources.dart';
|
||||
|
||||
void main() {
|
||||
group('PermissionResources.normalize', () {
|
||||
test('경로 별칭을 서버 표준 경로로 변환한다', () {
|
||||
expect(
|
||||
PermissionResources.normalize('/inventory/inbound'),
|
||||
PermissionResources.stockTransactions,
|
||||
);
|
||||
expect(
|
||||
PermissionResources.normalize('/approvals/templates'),
|
||||
PermissionResources.approvalTemplates,
|
||||
);
|
||||
});
|
||||
|
||||
test('대소문자/공백/슬래시를 정리한다', () {
|
||||
expect(
|
||||
PermissionResources.normalize(' inventory/inbound/ '),
|
||||
PermissionResources.stockTransactions,
|
||||
);
|
||||
expect(
|
||||
PermissionResources.normalize('stock-transactions'),
|
||||
PermissionResources.stockTransactions,
|
||||
);
|
||||
});
|
||||
|
||||
test('쿼리/프래그먼트/URL을 제거한다', () {
|
||||
expect(
|
||||
PermissionResources.normalize(
|
||||
'/Inventory/Inbound?page=2&status=submit',
|
||||
),
|
||||
PermissionResources.stockTransactions,
|
||||
);
|
||||
expect(
|
||||
PermissionResources.normalize(
|
||||
'https://example.com/approvals/templates#section',
|
||||
),
|
||||
PermissionResources.approvalTemplates,
|
||||
);
|
||||
});
|
||||
|
||||
test('비어 있는 입력은 빈 문자열을 반환한다', () {
|
||||
expect(PermissionResources.normalize(' '), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user