chore: API 오류 매핑과 Failure 파서 고도화

This commit is contained in:
JiWoong Sul
2025-10-14 18:06:25 +09:00
parent 67fc319c3c
commit 9f61b305d4
7 changed files with 1000 additions and 71 deletions

View 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'));
});
}

View 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'));
});
}