Files
reelbox/test/api_config_recovery_test.dart

253 lines
7.9 KiB
Dart

import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api_service_test.dart' as fixtures;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
test('Iran migration is restored by a fresh instance without contacting discovery', () async {
final adapter = fixtures.StubAdapter(
(request) => request.uri.host == 'ninnies-server.info'
? ResponseBody.fromString(ApiService.defaultBaseUrl, 200)
: fixtures.jsonBody([
{'id': 1, 'title': 'Drama'},
]),
);
await ApiService(dio: Dio()..httpClientAdapter = adapter).initialize();
var reads = 0;
final offline = fixtures.StubAdapter(
(_) => throw StateError('No network permitted'),
);
final restored = ApiService(
dio: Dio()..httpClientAdapter = offline,
preferences: () async {
reads++;
return SharedPreferences.getInstance();
},
);
await restored.initialize(discover: false);
expect(reads, 1);
expect(restored.baseUrl, ApiService.defaultBaseUrl);
expect(offline.requests, isEmpty);
});
for (final value in [
'broken',
'https://untrusted.example/api/',
'http://ninwinios.info/api/',
'https://fixture:fixture@ninwinios.info/api/',
'https://ninwinios.info/api/?x=1',
'https://ninwinios.info/api/#fragment',
'https://127.0.0.1/api/',
'https://ninwinios.info:444/api/',
]) {
test(
'invalid cached configuration cannot change the trusted destination: ${Uri.tryParse(value)?.host ?? 'malformed'}',
() async {
SharedPreferences.setMockInitialValues({
'reelbox.trusted-api.v1': value,
});
final adapter = fixtures.StubAdapter(
(_) => throw StateError('No network permitted'),
);
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
await api.initialize(discover: false);
expect(api.baseUrl, ApiService.defaultBaseUrl);
expect(adapter.requests, isEmpty);
},
);
}
test(
'removed discovery cannot contact an offline or invalid-schema server',
() async {
SharedPreferences.setMockInitialValues({
'reelbox.trusted-api.v1': ApiService.defaultBaseUrl,
});
final offline = fixtures.StubAdapter(
(r) => throw DioException(
requestOptions: r,
type: DioExceptionType.connectionError,
),
);
final api = ApiService(dio: Dio()..httpClientAdapter = offline);
await api.initialize();
expect(api.baseUrl, ApiService.defaultBaseUrl);
expect(offline.requests, isEmpty);
expect(
(await SharedPreferences.getInstance()).getString(
'reelbox.trusted-api.v1',
),
isNull,
);
SharedPreferences.setMockInitialValues({});
final invalid = fixtures.StubAdapter(
(r) => r.uri.host == 'ninnies-server.info'
? ResponseBody.fromString(ApiService.defaultBaseUrl, 200)
: fixtures.jsonBody([]),
);
await ApiService(dio: Dio()..httpClientAdapter = invalid).initialize();
expect(invalid.requests, isEmpty);
expect(
(await SharedPreferences.getInstance()).getString(
'reelbox.trusted-api.v1',
),
isNull,
);
},
);
test(
'read-only transport failure recovers once without a discovery storm',
() async {
var attempts = 0;
final adapter = fixtures.StubAdapter((r) {
if (++attempts == 1) {
throw DioException(
requestOptions: r,
type: DioExceptionType.connectionError,
);
}
return fixtures.jsonBody([
{'id': 1, 'title': 'Drama'},
]);
});
final result = await ApiService(dio: Dio()..httpClientAdapter = adapter)
.genres();
expect(result.single.title, 'Drama');
expect(attempts, 2);
expect(
adapter.requests.every(
(r) => r.uri.origin == 'https://reelbox.dartty.top',
),
true,
);
},
);
test('persistent transport failure is bounded to two GET attempts', () async {
final adapter = fixtures.StubAdapter(
(r) => throw DioException(
requestOptions: r,
type: DioExceptionType.receiveTimeout,
),
);
await expectLater(
ApiService(dio: Dio()..httpClientAdapter = adapter).genres(),
throwsA(isA<ApiException>()),
);
expect(adapter.requests, hasLength(2));
});
for (final status in [401, 403, 404, 429, 500]) {
test('HTTP $status is preserved without automatic retry', () async {
final adapter = fixtures.StubAdapter(
(_) => fixtures.jsonBody({}, status: status),
);
await expectLater(
ApiService(dio: Dio()..httpClientAdapter = adapter).genres(),
throwsA(
isA<ApiException>().having((e) => e.statusCode, 'status', status),
),
);
expect(adapter.requests, hasLength(1));
});
}
for (final type in [
DioExceptionType.cancel,
DioExceptionType.badCertificate,
]) {
test('$type is never retried', () async {
final adapter = fixtures.StubAdapter(
(r) => throw DioException(requestOptions: r, type: type),
);
await expectLater(
ApiService(dio: Dio()..httpClientAdapter = adapter).genres(),
throwsA(isA<ApiException>()),
);
expect(adapter.requests, hasLength(1));
});
}
test('POST transport failure is not replayed', () async {
final adapter = fixtures.StubAdapter(
(r) => throw DioException(
requestOptions: r,
type: DioExceptionType.connectionError,
),
);
await expectLater(
ApiService(dio: Dio()..httpClientAdapter = adapter)
.post('genre/read', {'fixture': true}),
throwsA(isA<ApiException>()),
);
expect(adapter.requests, hasLength(1));
});
test(
'concurrent failed GETs share cache loading and each retry only once',
() async {
var reads = 0;
final attempts = <String, int>{};
final adapter = fixtures.StubAdapter((r) {
final n = attempts.update(r.uri.path, (n) => n + 1, ifAbsent: () => 1);
if (n == 1) {
throw DioException(
requestOptions: r,
type: DioExceptionType.connectionError,
);
}
return fixtures.jsonBody([]);
});
final cache = Completer<SharedPreferences>();
final api = ApiService(
dio: Dio()..httpClientAdapter = adapter,
preferences: () {
reads++;
return cache.future;
},
);
final requests = Future.wait([
for (final n in [1, 2, 3]) api.get('fixture-$n'),
]);
await Future<void>.delayed(Duration.zero);
cache.complete(await SharedPreferences.getInstance());
await requests;
expect(reads, 1);
expect(attempts.values, everyElement(2));
expect(adapter.requests, hasLength(6));
},
);
test(
'production default persists Iran without fetching remote configuration',
() async {
SharedPreferences.setMockInitialValues({});
final adapter = fixtures.StubAdapter((request) {
if (request.uri.host == 'ninnies-server.info') {
return ResponseBody.fromString(ApiService.defaultBaseUrl, 200);
}
return fixtures.jsonBody([
{'id': 1, 'title': 'Drama'},
]);
});
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
await api.initialize();
final preferences = await SharedPreferences.getInstance();
expect(
preferences.getString('reelbox.api-selection.v1'),
'primary',
);
expect(preferences.getString('reelbox.trusted-api.v1'), isNull);
expect(adapter.requests, isEmpty);
},
);
}