The pubspec pinned Flutter 3.44.5 while the toolchain had moved to
3.44.9, so `flutter pub upgrade` failed version solving outright and no
dependency had been upgradable for a while. Bumping the pin unblocks
resolution (CI reads the same field via flutter-version-file).
Notable bumps: dio 5.9.2 -> 5.11.0, firebase_core 4.10 -> 4.13 (plus
crashlytics/analytics/performance), app_links 7.2.1, flutter_markdown_plus
1.0.12, intl, path_provider, webview_flutter, flutter_cache_manager.
dio 5.11 adds DioExceptionType.transformTimeout, which broke the
exhaustive switch in BaseApiClient._handleError. Grouped it with the
other timeout types and covered all four mappings with tests, which
were previously untested.
Raised the json_annotation floor to ^4.12.0 to silence a
json_serializable build warning; the resolved version was already 4.12.0.
flutter_secure_storage stays on v9 deliberately. v10.0.0 shipped in 5.0.0
and was reverted in 5.0.1 (ba28e09) while chasing the #484 startup freeze
on low-end Android 14, and upstream #1084 (unrecoverable NPE in
initialize() on Android 13) is still open. v9 pins win32 ^5, so
device_info_plus, package_info_plus and wakelock_plus are held at their
current majors too - their only breaking change was a win32 6.0.0 bump
that affects Windows only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
438 lines
11 KiB
Dart
438 lines
11 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:frosty/apis/base_api_client.dart';
|
|
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
|
|
|
/// Concrete test subclass to test BaseApiClient through its public interface.
|
|
class TestApiClient extends BaseApiClient {
|
|
TestApiClient(Dio dio) : super(dio, 'https://api.test.com');
|
|
|
|
/// Test wrapper that exercises the error handling path through get().
|
|
Future<dynamic> testGet(String endpoint) => get(endpoint);
|
|
}
|
|
|
|
void main() {
|
|
late Dio dio;
|
|
late DioAdapter dioAdapter;
|
|
late TestApiClient client;
|
|
|
|
setUp(() {
|
|
dio = Dio(BaseOptions());
|
|
dioAdapter = DioAdapter(dio: dio);
|
|
client = TestApiClient(dio);
|
|
});
|
|
|
|
group('isSuccessResponse', () {
|
|
test('returns true for 200', () {
|
|
final response = Response(
|
|
requestOptions: RequestOptions(),
|
|
statusCode: 200,
|
|
);
|
|
expect(client.isSuccessResponse(response), isTrue);
|
|
});
|
|
|
|
test('returns true for 201', () {
|
|
final response = Response(
|
|
requestOptions: RequestOptions(),
|
|
statusCode: 201,
|
|
);
|
|
expect(client.isSuccessResponse(response), isTrue);
|
|
});
|
|
|
|
test('returns true for 204', () {
|
|
final response = Response(
|
|
requestOptions: RequestOptions(),
|
|
statusCode: 204,
|
|
);
|
|
expect(client.isSuccessResponse(response), isTrue);
|
|
});
|
|
|
|
test('returns false for 300', () {
|
|
final response = Response(
|
|
requestOptions: RequestOptions(),
|
|
statusCode: 300,
|
|
);
|
|
expect(client.isSuccessResponse(response), isFalse);
|
|
});
|
|
|
|
test('returns false for 400', () {
|
|
final response = Response(
|
|
requestOptions: RequestOptions(),
|
|
statusCode: 400,
|
|
);
|
|
expect(client.isSuccessResponse(response), isFalse);
|
|
});
|
|
|
|
test('returns false for 500', () {
|
|
final response = Response(
|
|
requestOptions: RequestOptions(),
|
|
statusCode: 500,
|
|
);
|
|
expect(client.isSuccessResponse(response), isFalse);
|
|
});
|
|
|
|
test('returns false for null statusCode', () {
|
|
final response = Response(requestOptions: RequestOptions());
|
|
expect(client.isSuccessResponse(response), isFalse);
|
|
});
|
|
});
|
|
|
|
group('error handling through get()', () {
|
|
test('400 throws ApiException', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/test',
|
|
(server) => server.reply(400, {'message': 'Bad field'}),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/test'),
|
|
throwsA(
|
|
isA<ApiException>().having((e) => e.message, 'message', 'Bad field'),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('401 throws UnauthorizedException', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/auth',
|
|
(server) => server.reply(401, {'message': 'Unauthorized'}),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/auth'),
|
|
throwsA(isA<UnauthorizedException>()),
|
|
);
|
|
});
|
|
|
|
test('404 throws NotFoundException', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/missing',
|
|
(server) => server.reply(404, {'message': 'User not found'}),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/missing'),
|
|
throwsA(
|
|
isA<NotFoundException>().having(
|
|
(e) => e.message,
|
|
'message',
|
|
'User not found',
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('404 with no server message uses default', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/empty404',
|
|
(server) => server.reply(404, 'not json'),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/empty404'),
|
|
throwsA(
|
|
isA<NotFoundException>().having(
|
|
(e) => e.message,
|
|
'message',
|
|
'Resource not found',
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('409 throws ApiException about conflict', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/conflict',
|
|
(server) => server.reply(409, null),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/conflict'),
|
|
throwsA(
|
|
isA<ApiException>().having(
|
|
(e) => e.message,
|
|
'message',
|
|
contains('Conflict'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('429 throws ApiException about rate limit', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/ratelimit',
|
|
(server) => server.reply(429, null),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/ratelimit'),
|
|
throwsA(
|
|
isA<ApiException>().having(
|
|
(e) => e.message,
|
|
'message',
|
|
contains('Rate limit'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('500 throws ServerException', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/server-error',
|
|
(server) => server.reply(500, null),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/server-error'),
|
|
throwsA(
|
|
isA<ServerException>().having(
|
|
(e) => e.statusCode,
|
|
'statusCode',
|
|
500,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('502 throws ServerException', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/bad-gateway',
|
|
(server) => server.reply(502, null),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/bad-gateway'),
|
|
throwsA(
|
|
isA<ServerException>().having(
|
|
(e) => e.statusCode,
|
|
'statusCode',
|
|
502,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('503 throws ServerException', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/maintenance',
|
|
(server) => server.reply(503, null),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/maintenance'),
|
|
throwsA(isA<ServerException>()),
|
|
);
|
|
});
|
|
|
|
test('504 throws ServerException', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/timeout',
|
|
(server) => server.reply(504, null),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/timeout'),
|
|
throwsA(isA<ServerException>()),
|
|
);
|
|
});
|
|
|
|
test('422 uses server message from error field', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/unprocessable',
|
|
(server) => server.reply(422, {'error': 'Invalid email'}),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/unprocessable'),
|
|
throwsA(
|
|
isA<ApiException>().having(
|
|
(e) => e.message,
|
|
'message',
|
|
'Invalid email',
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('400 uses error_description field', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/oauth-error',
|
|
(server) => server.reply(
|
|
400,
|
|
{'error_description': 'OAuth error description'},
|
|
),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/oauth-error'),
|
|
throwsA(
|
|
isA<ApiException>().having(
|
|
(e) => e.message,
|
|
'message',
|
|
'OAuth error description',
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('400 prefers message field over error field', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/priority',
|
|
(server) => server.reply(400, {
|
|
'message': 'From message',
|
|
'error': 'From error',
|
|
}),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/priority'),
|
|
throwsA(
|
|
isA<ApiException>().having(
|
|
(e) => e.message,
|
|
'message',
|
|
'From message',
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('403 returns permission error', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/forbidden',
|
|
(server) => server.reply(403, null),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/forbidden'),
|
|
throwsA(
|
|
isA<ApiException>().having(
|
|
(e) => e.message,
|
|
'message',
|
|
contains('permissions'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('unknown status code throws ServerException', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/teapot',
|
|
(server) => server.reply(418, null),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet('/teapot'),
|
|
throwsA(
|
|
isA<ServerException>().having(
|
|
(e) => e.statusCode,
|
|
'statusCode',
|
|
418,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
const timeoutCases = {
|
|
DioExceptionType.connectionTimeout: 'Connection timeout',
|
|
DioExceptionType.sendTimeout: 'Upload timeout',
|
|
DioExceptionType.receiveTimeout: 'Server response timeout',
|
|
DioExceptionType.transformTimeout: 'Response processing timeout',
|
|
};
|
|
|
|
timeoutCases.forEach((type, expectedMessage) {
|
|
test('${type.name} throws TimeoutException', () async {
|
|
final path = '/${type.name}';
|
|
|
|
dioAdapter.onGet(
|
|
'https://api.test.com$path',
|
|
(server) => server.throws(
|
|
0,
|
|
DioException(
|
|
requestOptions: RequestOptions(path: path),
|
|
type: type,
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
() => client.testGet(path),
|
|
throwsA(
|
|
isA<TimeoutException>().having(
|
|
(e) => e.message,
|
|
'message',
|
|
contains(expectedMessage),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
group('successful get()', () {
|
|
test('returns data on success', () async {
|
|
dioAdapter.onGet(
|
|
'https://api.test.com/users',
|
|
(server) => server.reply(200, {'data': 'test'}),
|
|
);
|
|
|
|
final result = await client.testGet('/users');
|
|
expect(result, {'data': 'test'});
|
|
});
|
|
|
|
test('handles absolute URL', () async {
|
|
dioAdapter.onGet(
|
|
'https://other.com/endpoint',
|
|
(server) => server.reply(200, {'result': 'ok'}),
|
|
);
|
|
|
|
final result = await client.testGet('https://other.com/endpoint');
|
|
expect(result, {'result': 'ok'});
|
|
});
|
|
});
|
|
|
|
group('ApiException', () {
|
|
test('toString includes message', () {
|
|
const exception = ApiException('Test error');
|
|
expect(exception.toString(), 'ApiException: Test error');
|
|
});
|
|
|
|
test('stores status code and type', () {
|
|
const exception = ApiException(
|
|
'Error',
|
|
404,
|
|
DioExceptionType.badResponse,
|
|
);
|
|
expect(exception.statusCode, 404);
|
|
expect(exception.type, DioExceptionType.badResponse);
|
|
});
|
|
});
|
|
|
|
group('Exception subtypes', () {
|
|
test('NetworkException has connectionError type', () {
|
|
const exception = NetworkException('No network');
|
|
expect(exception.type, DioExceptionType.connectionError);
|
|
expect(exception.statusCode, isNull);
|
|
});
|
|
|
|
test('TimeoutException has connectionTimeout type', () {
|
|
const exception = TimeoutException('Timed out');
|
|
expect(exception.type, DioExceptionType.connectionTimeout);
|
|
});
|
|
|
|
test('ServerException has badResponse type and status code', () {
|
|
const exception = ServerException('Server error', 500);
|
|
expect(exception.type, DioExceptionType.badResponse);
|
|
expect(exception.statusCode, 500);
|
|
});
|
|
|
|
test('NotFoundException has 404 status code', () {
|
|
const exception = NotFoundException('Not found');
|
|
expect(exception.statusCode, 404);
|
|
});
|
|
|
|
test('UnauthorizedException has 401 status code', () {
|
|
const exception = UnauthorizedException('Unauthorized');
|
|
expect(exception.statusCode, 401);
|
|
});
|
|
});
|
|
}
|