chore: 통합 테스트 환경과 보고서 리모트 구성

This commit is contained in:
JiWoong Sul
2025-10-14 18:11:57 +09:00
parent 8067416c09
commit 7e0f7b1c55
25 changed files with 1608 additions and 1 deletions

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

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