주요 변경사항: - 창고 관리 API 응답 구조와 DTO 불일치 수정 - WarehouseLocationDto에 code, manager_phone 필드 추가 - RemoteDataSource에서 API 응답을 DTO 구조에 맞게 변환 - 회사 관리 API 응답 파싱 오류 수정 - CompanyResponse의 필수 필드를 nullable로 변경 - PaginatedResponse 구조 매핑 로직 개선 - 에러 처리 및 로깅 개선 - Service Layer에 상세 에러 로깅 추가 - Controller에서 에러 타입별 처리 - 새로운 유틸리티 추가 - ResponseInterceptor: API 응답 정규화 - DebugLogger: 디버깅 도구 - HealthCheckService: 서버 상태 확인 - 문서화 - API 통합 테스트 가이드 - 에러 분석 보고서 - 리팩토링 계획서
339 lines
11 KiB
Dart
339 lines
11 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:dio/dio.dart';
|
|
import 'package:mockito/mockito.dart';
|
|
import 'package:mockito/annotations.dart';
|
|
import 'package:dartz/dartz.dart';
|
|
import 'package:superport/data/datasources/remote/api_client.dart';
|
|
import 'package:superport/data/datasources/remote/auth_remote_datasource.dart';
|
|
import 'package:superport/data/models/auth/login_request.dart';
|
|
import 'package:superport/data/models/auth/login_response.dart';
|
|
import 'package:superport/data/models/auth/auth_user.dart';
|
|
import 'package:superport/core/errors/failures.dart';
|
|
import 'package:superport/core/utils/debug_logger.dart';
|
|
|
|
import 'auth_api_integration_test.mocks.dart';
|
|
|
|
@GenerateMocks([Dio])
|
|
void main() {
|
|
group('Auth API 통합 테스트 - 실제 API 동작 시뮬레이션', () {
|
|
late MockDio mockDio;
|
|
late ApiClient apiClient;
|
|
late AuthRemoteDataSource authDataSource;
|
|
|
|
setUp(() {
|
|
mockDio = MockDio();
|
|
// ApiClient를 직접 생성하는 대신 mockDio를 주입
|
|
apiClient = ApiClient();
|
|
// Reflection을 사용해 private _dio 필드에 접근 (테스트 목적)
|
|
// 실제로는 ApiClient에 테스트용 생성자를 추가하는 것이 좋음
|
|
authDataSource = AuthRemoteDataSourceImpl(apiClient);
|
|
});
|
|
|
|
test('Case 1: API가 success/data 형식으로 응답하는 경우', () async {
|
|
// Arrange
|
|
final request = LoginRequest(
|
|
email: 'admin@superport.com',
|
|
password: 'admin123',
|
|
);
|
|
|
|
final apiResponse = {
|
|
'success': true,
|
|
'data': {
|
|
'access_token': 'jwt_token_123456',
|
|
'refresh_token': 'refresh_token_789',
|
|
'token_type': 'Bearer',
|
|
'expires_in': 3600,
|
|
'user': {
|
|
'id': 1,
|
|
'username': 'admin',
|
|
'email': 'admin@superport.com',
|
|
'name': '시스템 관리자',
|
|
'role': 'ADMIN',
|
|
},
|
|
},
|
|
};
|
|
|
|
// Act & Assert
|
|
print('\n=== Case 1: success/data 래핑 형식 ===');
|
|
print('요청 데이터: ${request.toJson()}');
|
|
print('예상 응답: $apiResponse');
|
|
|
|
// 실제 API 호출 시뮬레이션
|
|
try {
|
|
// AuthRemoteDataSourceImpl의 로직 검증
|
|
final responseData = apiResponse;
|
|
|
|
if (responseData['success'] == true && responseData['data'] != null) {
|
|
print('✅ 응답 형식 1 감지 (success/data 래핑)');
|
|
|
|
final loginData = responseData['data'] as Map<String, dynamic>;
|
|
final loginResponse = LoginResponse.fromJson(loginData);
|
|
|
|
print('파싱 성공:');
|
|
print(' - Access Token: ${loginResponse.accessToken}');
|
|
print(' - User Email: ${loginResponse.user.email}');
|
|
print(' - User Role: ${loginResponse.user.role}');
|
|
|
|
expect(loginResponse.accessToken, 'jwt_token_123456');
|
|
expect(loginResponse.user.email, 'admin@superport.com');
|
|
expect(loginResponse.user.role, 'ADMIN');
|
|
}
|
|
} catch (e, stackTrace) {
|
|
print('❌ 파싱 실패: $e');
|
|
print('스택 트레이스: $stackTrace');
|
|
fail('success/data 형식 파싱에 실패했습니다');
|
|
}
|
|
});
|
|
|
|
test('Case 2: API가 직접 LoginResponse 형식으로 응답하는 경우', () async {
|
|
// Arrange
|
|
final request = LoginRequest(
|
|
username: 'testuser',
|
|
password: 'password123',
|
|
);
|
|
|
|
final apiResponse = {
|
|
'access_token': 'direct_token_456',
|
|
'refresh_token': 'direct_refresh_789',
|
|
'token_type': 'Bearer',
|
|
'expires_in': 7200,
|
|
'user': {
|
|
'id': 2,
|
|
'username': 'testuser',
|
|
'email': 'test@example.com',
|
|
'name': '일반 사용자',
|
|
'role': 'USER',
|
|
},
|
|
};
|
|
|
|
// Act & Assert
|
|
print('\n=== Case 2: 직접 응답 형식 ===');
|
|
print('요청 데이터: ${request.toJson()}');
|
|
print('예상 응답: $apiResponse');
|
|
|
|
try {
|
|
// 직접 응답 형식 처리
|
|
if (apiResponse.containsKey('access_token')) {
|
|
print('✅ 응답 형식 2 감지 (직접 응답)');
|
|
|
|
final loginResponse = LoginResponse.fromJson(apiResponse);
|
|
|
|
print('파싱 성공:');
|
|
print(' - Access Token: ${loginResponse.accessToken}');
|
|
print(' - User Username: ${loginResponse.user.username}');
|
|
print(' - User Role: ${loginResponse.user.role}');
|
|
|
|
expect(loginResponse.accessToken, 'direct_token_456');
|
|
expect(loginResponse.user.username, 'testuser');
|
|
expect(loginResponse.user.role, 'USER');
|
|
}
|
|
} catch (e, stackTrace) {
|
|
print('❌ 파싱 실패: $e');
|
|
print('스택 트레이스: $stackTrace');
|
|
fail('직접 응답 형식 파싱에 실패했습니다');
|
|
}
|
|
});
|
|
|
|
test('Case 3: camelCase 필드명 사용 시 에러', () async {
|
|
// Arrange
|
|
final apiResponse = {
|
|
'accessToken': 'camel_token_123', // camelCase
|
|
'refreshToken': 'camel_refresh_456',
|
|
'tokenType': 'Bearer',
|
|
'expiresIn': 3600,
|
|
'user': {
|
|
'id': 3,
|
|
'username': 'cameluser',
|
|
'email': 'camel@test.com',
|
|
'name': 'Camel User',
|
|
'role': 'USER',
|
|
},
|
|
};
|
|
|
|
// Act & Assert
|
|
print('\n=== Case 3: camelCase 필드명 에러 ===');
|
|
print('예상 응답: $apiResponse');
|
|
|
|
try {
|
|
final loginResponse = LoginResponse.fromJson(apiResponse);
|
|
fail('camelCase 응답이 성공하면 안됩니다');
|
|
} catch (e) {
|
|
print('✅ 예상된 에러 발생: $e');
|
|
expect(e.toString(), contains('type \'Null\' is not a subtype of type \'String\''));
|
|
}
|
|
});
|
|
|
|
test('Case 4: 401 인증 실패 응답', () async {
|
|
// Arrange
|
|
final request = LoginRequest(
|
|
email: 'wrong@email.com',
|
|
password: 'wrongpassword',
|
|
);
|
|
|
|
// Act & Assert
|
|
print('\n=== Case 4: 401 인증 실패 ===');
|
|
print('요청 데이터: ${request.toJson()}');
|
|
|
|
// DioException 시뮬레이션
|
|
final dioError = DioException(
|
|
response: Response(
|
|
statusCode: 401,
|
|
data: {
|
|
'message': 'Invalid credentials',
|
|
'error': 'Unauthorized',
|
|
},
|
|
requestOptions: RequestOptions(path: '/auth/login'),
|
|
),
|
|
requestOptions: RequestOptions(path: '/auth/login'),
|
|
type: DioExceptionType.badResponse,
|
|
);
|
|
|
|
print('응답 상태: 401 Unauthorized');
|
|
print('에러 메시지: Invalid credentials');
|
|
|
|
// AuthRemoteDataSourceImpl의 에러 처리 로직 검증
|
|
expect(dioError.response?.statusCode, 401);
|
|
|
|
// 예상되는 Failure 타입
|
|
print('✅ AuthenticationFailure로 변환되어야 함');
|
|
});
|
|
|
|
test('Case 5: 네트워크 타임아웃', () async {
|
|
// Arrange
|
|
final request = LoginRequest(
|
|
email: 'test@example.com',
|
|
password: 'password',
|
|
);
|
|
|
|
// Act & Assert
|
|
print('\n=== Case 5: 네트워크 타임아웃 ===');
|
|
print('요청 데이터: ${request.toJson()}');
|
|
|
|
final dioError = DioException(
|
|
requestOptions: RequestOptions(path: '/auth/login'),
|
|
type: DioExceptionType.connectionTimeout,
|
|
message: 'Connection timeout',
|
|
);
|
|
|
|
print('에러 타입: ${dioError.type}');
|
|
print('에러 메시지: ${dioError.message}');
|
|
|
|
// 예상되는 Failure 타입
|
|
print('✅ NetworkFailure로 변환되어야 함');
|
|
});
|
|
|
|
test('Case 6: 잘못된 JSON 응답', () async {
|
|
// Arrange
|
|
final apiResponse = {
|
|
'error': 'Invalid request',
|
|
'status': 'failed',
|
|
// access_token 등 필수 필드 누락
|
|
};
|
|
|
|
// Act & Assert
|
|
print('\n=== Case 6: 잘못된 JSON 응답 ===');
|
|
print('예상 응답: $apiResponse');
|
|
|
|
try {
|
|
final loginResponse = LoginResponse.fromJson(apiResponse);
|
|
fail('잘못된 JSON이 파싱되면 안됩니다');
|
|
} catch (e) {
|
|
print('✅ 예상된 에러 발생: $e');
|
|
expect(e.toString(), contains('type \'Null\' is not a subtype'));
|
|
}
|
|
});
|
|
|
|
test('Case 7: ResponseInterceptor 동작 검증', () async {
|
|
// ResponseInterceptor가 다양한 응답을 어떻게 처리하는지 검증
|
|
print('\n=== Case 7: ResponseInterceptor 동작 검증 ===');
|
|
|
|
final testCases = [
|
|
{
|
|
'name': '이미 정규화된 응답',
|
|
'input': {
|
|
'success': true,
|
|
'data': {'access_token': 'token1'},
|
|
},
|
|
'expected': {
|
|
'success': true,
|
|
'data': {'access_token': 'token1'},
|
|
},
|
|
},
|
|
{
|
|
'name': '직접 데이터 응답 (access_token)',
|
|
'input': {
|
|
'access_token': 'token2',
|
|
'user': {'id': 1},
|
|
},
|
|
'expected': {
|
|
'success': true,
|
|
'data': {
|
|
'access_token': 'token2',
|
|
'user': {'id': 1},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
for (final testCase in testCases) {
|
|
print('\n테스트: ${testCase['name']}');
|
|
print('입력: ${testCase['input']}');
|
|
print('예상 출력: ${testCase['expected']}');
|
|
|
|
// ResponseInterceptor 로직 시뮬레이션
|
|
final input = testCase['input'] as Map<String, dynamic>;
|
|
Map<String, dynamic> output;
|
|
|
|
if (input.containsKey('success') && input.containsKey('data')) {
|
|
output = input; // 이미 정규화됨
|
|
} else if (input.containsKey('access_token') || input.containsKey('accessToken')) {
|
|
output = {
|
|
'success': true,
|
|
'data': input,
|
|
};
|
|
} else {
|
|
output = input;
|
|
}
|
|
|
|
print('실제 출력: $output');
|
|
expect(output, equals(testCase['expected']));
|
|
}
|
|
});
|
|
});
|
|
|
|
group('에러 메시지 및 스택 트레이스 분석', () {
|
|
test('실제 에러 시나리오 재현', () {
|
|
print('\n=== 실제 에러 시나리오 재현 ===\n');
|
|
|
|
// 테스트에서 발생한 실제 에러들
|
|
final errors = [
|
|
{
|
|
'scenario': 'Future.timeout 타입 에러',
|
|
'error': "type '() => Left<Failure, LoginResponse>' is not a subtype of type '(() => FutureOr<Right<Failure, LoginResponse>>)?'",
|
|
'cause': 'timeout의 onTimeout 콜백이 잘못된 타입을 반환',
|
|
'solution': 'onTimeout이 Future<Either<Failure, LoginResponse>>를 반환하도록 수정',
|
|
},
|
|
{
|
|
'scenario': 'JSON 파싱 null 에러',
|
|
'error': "type 'Null' is not a subtype of type 'String' in type cast",
|
|
'cause': 'snake_case 필드명 기대하지만 camelCase로 전달됨',
|
|
'solution': 'API 응답 형식 확인 및 모델 수정',
|
|
},
|
|
{
|
|
'scenario': '위젯 테스트 tap 실패',
|
|
'error': "could not be tapped on because it has not been laid out yet",
|
|
'cause': '위젯이 아직 렌더링되지 않은 상태에서 tap 시도',
|
|
'solution': 'await tester.pumpAndSettle() 추가',
|
|
},
|
|
];
|
|
|
|
for (final error in errors) {
|
|
print('시나리오: ${error['scenario']}');
|
|
print('에러: ${error['error']}');
|
|
print('원인: ${error['cause']}');
|
|
print('해결책: ${error['solution']}');
|
|
print('---\n');
|
|
}
|
|
});
|
|
});
|
|
} |