merge: 合并 K65-A 冷启动首页预取
This commit is contained in:
commit
c76b388814
7 files changed
+255
-113
No files matched your search
@@ -28,6 +28,12 @@ class AppConfig {
|
|||||||
|
|
||||||
static String _runtimeBaseUrl = '';
|
static String _runtimeBaseUrl = '';
|
||||||
|
|
||||||
|
static Future<void>? _ready;
|
||||||
|
|
||||||
|
/// 启动地址探测完成信号;默认已完成,避免影响既有测试与调用方。
|
||||||
|
static Future<void> get ready => _ready ?? Future<void>.value();
|
||||||
|
static set ready(Future<void> value) => _ready = value;
|
||||||
|
|
||||||
static const Duration _probeTimeout = Duration(seconds: 2);
|
static const Duration _probeTimeout = Duration(seconds: 2);
|
||||||
static const List<String> _probeCandidates = [
|
static const List<String> _probeCandidates = [
|
||||||
lanBaseUrl,
|
lanBaseUrl,
|
||||||
@@ -85,10 +91,15 @@ class AppConfig {
|
|||||||
for (final candidate in _probeCandidates)
|
for (final candidate in _probeCandidates)
|
||||||
if (candidate != _runtimeBaseUrl) candidate,
|
if (candidate != _runtimeBaseUrl) candidate,
|
||||||
];
|
];
|
||||||
for (final candidate in candidates) {
|
final results = await Future.wait([
|
||||||
final result = probe == null
|
for (final candidate in candidates)
|
||||||
? await _probeBaseUrl(candidate)
|
probe == null
|
||||||
: await _runInjectedProbe(candidate, probe);
|
? _probeBaseUrl(candidate)
|
||||||
|
: _runInjectedProbe(candidate, probe),
|
||||||
|
]);
|
||||||
|
for (var index = 0; index < candidates.length; index++) {
|
||||||
|
final candidate = candidates[index];
|
||||||
|
final result = results[index];
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
final status = result.ok
|
final status = result.ok
|
||||||
? 'ok'
|
? 'ok'
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class ApiClient {
|
|||||||
this.tokenRefresher,
|
this.tokenRefresher,
|
||||||
this.onUnauthorized,
|
this.onUnauthorized,
|
||||||
this.deviceIdReader,
|
this.deviceIdReader,
|
||||||
}) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl {
|
}) : _fixedBaseUrl = baseUrl {
|
||||||
_dio = Dio(
|
_dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: this.baseUrl,
|
baseUrl: this.baseUrl,
|
||||||
@@ -45,9 +45,12 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final String baseUrl;
|
final String? _fixedBaseUrl;
|
||||||
late final Dio _dio;
|
late final Dio _dio;
|
||||||
|
|
||||||
|
/// 当前请求使用的服务地址;未显式指定时跟随启动探测结果。
|
||||||
|
String get baseUrl => _fixedBaseUrl ?? AppConfig.apiBaseUrl;
|
||||||
|
|
||||||
/// 取当前 access token(未登录返回 null)
|
/// 取当前 access token(未登录返回 null)
|
||||||
final String? Function()? accessTokenReader;
|
final String? Function()? accessTokenReader;
|
||||||
|
|
||||||
@@ -110,6 +113,7 @@ class ApiClient {
|
|||||||
bool retried = false,
|
bool retried = false,
|
||||||
bool skipAuthRetry = false,
|
bool skipAuthRetry = false,
|
||||||
}) async {
|
}) async {
|
||||||
|
_syncBaseUrl();
|
||||||
final disableAuthRetry = skipAuthRetry || tokenRefresher == null;
|
final disableAuthRetry = skipAuthRetry || tokenRefresher == null;
|
||||||
try {
|
try {
|
||||||
final response = await _dio.request<Object?>(
|
final response = await _dio.request<Object?>(
|
||||||
@@ -150,5 +154,10 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _syncBaseUrl() {
|
||||||
|
if (_fixedBaseUrl != null) return;
|
||||||
|
_dio.options.baseUrl = AppConfig.apiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
void close() => _dio.close(force: true);
|
void close() => _dio.close(force: true);
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,7 @@ class CatalogController extends ChangeNotifier {
|
|||||||
debugError = null;
|
debugError = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
|
await AppConfig.ready;
|
||||||
feed = await repository.fetchHomeFeed();
|
feed = await repository.fetchHomeFeed();
|
||||||
} on ApiException catch (exception) {
|
} on ApiException catch (exception) {
|
||||||
error = exception.message;
|
error = exception.message;
|
||||||
|
|||||||
+1
-1
@@ -13,6 +13,6 @@ Future<void> main() async {
|
|||||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
|
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
|
||||||
ServerEndpoint.load(),
|
ServerEndpoint.load(),
|
||||||
]);
|
]);
|
||||||
await AppConfig.probeAndApplyBaseUrl();
|
AppConfig.ready = AppConfig.probeAndApplyBaseUrl();
|
||||||
runApp(const LumaDramaApp());
|
runApp(const LumaDramaApp());
|
||||||
}
|
}
|
||||||
@@ -1,61 +1,102 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
||||||
|
import 'package:fruitshorts/core/config/app_config.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('should_distinguish_empty_catalog_from_load_failure_when_retry_succeeds', () async {
|
setUp(() {
|
||||||
|
AppConfig.clearRuntimeBaseUrl();
|
||||||
|
AppConfig.ready = Future<void>.value();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
AppConfig.clearRuntimeBaseUrl();
|
||||||
|
AppConfig.ready = Future<void>.value();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'should_distinguish_empty_catalog_from_load_failure_when_retry_succeeds',
|
||||||
|
() async {
|
||||||
|
final repository = ScriptedCatalogRepository();
|
||||||
|
final controller = CatalogController(repository);
|
||||||
|
|
||||||
|
expect(controller.settled, isFalse);
|
||||||
|
await controller.load();
|
||||||
|
expect(controller.settled, isTrue);
|
||||||
|
expect(controller.dramas, isEmpty);
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
|
||||||
|
repository.fail = true;
|
||||||
|
await controller.load();
|
||||||
|
expect(controller.error, isNotNull);
|
||||||
|
|
||||||
|
repository.fail = false;
|
||||||
|
repository.feed = HomeFeed(
|
||||||
|
sections: [
|
||||||
|
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await controller.load();
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
expect(controller.dramas, hasLength(1));
|
||||||
|
expect(controller.sections.single.title, '热门推荐');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'should_request_drama_detail_once_when_it_is_loaded_on_demand',
|
||||||
|
() async {
|
||||||
|
final repository = ScriptedCatalogRepository();
|
||||||
|
final controller = CatalogController(repository);
|
||||||
|
repository.feed = HomeFeed(
|
||||||
|
sections: [
|
||||||
|
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final card = controller.dramas.single;
|
||||||
|
expect(card.episodes, isEmpty);
|
||||||
|
|
||||||
|
final detail = await controller.requireDetail(card);
|
||||||
|
expect(detail.episodes, hasLength(2));
|
||||||
|
expect(repository.detailCalls, 1);
|
||||||
|
|
||||||
|
// 第二次已经是详情,直接复用,不再请求
|
||||||
|
await controller.requireDetail(detail);
|
||||||
|
expect(repository.detailCalls, 1);
|
||||||
|
expect(controller.dramas.single.episodes, hasLength(2));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('should_wait_for_ready_and_use_final_endpoint_before_loading', () async {
|
||||||
|
const finalBaseUrl = 'http://final.test:8081';
|
||||||
|
final ready = Completer<void>();
|
||||||
|
AppConfig.ready = ready.future;
|
||||||
final repository = ScriptedCatalogRepository();
|
final repository = ScriptedCatalogRepository();
|
||||||
final controller = CatalogController(repository);
|
final controller = CatalogController(repository);
|
||||||
|
|
||||||
expect(controller.settled, isFalse);
|
final loading = controller.load();
|
||||||
await controller.load();
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(controller.loading, isTrue);
|
||||||
|
expect(repository.homeCalls, 0);
|
||||||
|
|
||||||
|
AppConfig.applyRuntimeBaseUrl(finalBaseUrl);
|
||||||
|
ready.complete();
|
||||||
|
await loading;
|
||||||
|
|
||||||
|
expect(repository.homeCalls, 1);
|
||||||
|
expect(repository.requestBaseUrl, finalBaseUrl);
|
||||||
|
expect(controller.loading, isFalse);
|
||||||
expect(controller.settled, isTrue);
|
expect(controller.settled, isTrue);
|
||||||
expect(controller.dramas, isEmpty);
|
|
||||||
expect(controller.error, isNull);
|
|
||||||
|
|
||||||
repository.fail = true;
|
|
||||||
await controller.load();
|
|
||||||
expect(controller.error, isNotNull);
|
|
||||||
|
|
||||||
repository.fail = false;
|
|
||||||
repository.feed = HomeFeed(
|
|
||||||
sections: [
|
|
||||||
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
await controller.load();
|
|
||||||
expect(controller.error, isNull);
|
|
||||||
expect(controller.dramas, hasLength(1));
|
|
||||||
expect(controller.sections.single.title, '热门推荐');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should_request_drama_detail_once_when_it_is_loaded_on_demand', () async {
|
|
||||||
final repository = ScriptedCatalogRepository();
|
|
||||||
final controller = CatalogController(repository);
|
|
||||||
repository.feed = HomeFeed(
|
|
||||||
sections: [
|
|
||||||
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
final card = controller.dramas.single;
|
|
||||||
expect(card.episodes, isEmpty);
|
|
||||||
|
|
||||||
final detail = await controller.requireDetail(card);
|
|
||||||
expect(detail.episodes, hasLength(2));
|
|
||||||
expect(repository.detailCalls, 1);
|
|
||||||
|
|
||||||
// 第二次已经是详情,直接复用,不再请求
|
|
||||||
await controller.requireDetail(detail);
|
|
||||||
expect(repository.detailCalls, 1);
|
|
||||||
expect(controller.dramas.single.episodes, hasLength(2));
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Drama _card(String id) => Drama(
|
Drama _card(String id) => Drama(
|
||||||
@@ -81,9 +122,13 @@ class ScriptedCatalogRepository implements CatalogRepository {
|
|||||||
bool fail = false;
|
bool fail = false;
|
||||||
HomeFeed feed = const HomeFeed();
|
HomeFeed feed = const HomeFeed();
|
||||||
int detailCalls = 0;
|
int detailCalls = 0;
|
||||||
|
int homeCalls = 0;
|
||||||
|
String? requestBaseUrl;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<HomeFeed> fetchHomeFeed() async {
|
Future<HomeFeed> fetchHomeFeed() async {
|
||||||
|
homeCalls++;
|
||||||
|
requestBaseUrl = AppConfig.apiBaseUrl;
|
||||||
if (fail) throw StateError('offline');
|
if (fail) throw StateError('offline');
|
||||||
return feed;
|
return feed;
|
||||||
}
|
}
|
||||||
@@ -96,5 +141,4 @@ class ScriptedCatalogRepository implements CatalogRepository {
|
|||||||
detailCalls++;
|
detailCalls++;
|
||||||
return _detail(dramaId);
|
return _detail(dramaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||||
@@ -11,9 +13,13 @@ void main() {
|
|||||||
SharedPreferencesAsyncPlatform.instance =
|
SharedPreferencesAsyncPlatform.instance =
|
||||||
InMemorySharedPreferencesAsync.empty();
|
InMemorySharedPreferencesAsync.empty();
|
||||||
AppConfig.clearRuntimeBaseUrl();
|
AppConfig.clearRuntimeBaseUrl();
|
||||||
|
AppConfig.ready = Future<void>.value();
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(AppConfig.clearRuntimeBaseUrl);
|
tearDown(() {
|
||||||
|
AppConfig.clearRuntimeBaseUrl();
|
||||||
|
AppConfig.ready = Future<void>.value();
|
||||||
|
});
|
||||||
|
|
||||||
test('should_normalize_http_urls_and_reject_invalid_values', () {
|
test('should_normalize_http_urls_and_reject_invalid_values', () {
|
||||||
expect(
|
expect(
|
||||||
@@ -60,20 +66,39 @@ void main() {
|
|||||||
expect(AppConfig.devHostBaseUrl, 'http://192.168.31.187:8081');
|
expect(AppConfig.devHostBaseUrl, 'http://192.168.31.187:8081');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should_keep_loopback_priority_when_probe_succeeds', () async {
|
test(
|
||||||
final attempts = <String>[];
|
'should_probe_all_candidates_in_parallel_before_applying_priority',
|
||||||
|
() async {
|
||||||
|
final attempts = <String>[];
|
||||||
|
var active = 0;
|
||||||
|
var maxActive = 0;
|
||||||
|
final allStarted = Completer<void>();
|
||||||
|
|
||||||
await AppConfig.probeAndApplyBaseUrl(
|
final probing = AppConfig.probeAndApplyBaseUrl(
|
||||||
fallbackBaseUrl: AppConfig.devHostBaseUrl,
|
fallbackBaseUrl: AppConfig.devHostBaseUrl,
|
||||||
probe: (baseUrl) async {
|
probe: (baseUrl) async {
|
||||||
attempts.add(baseUrl);
|
attempts.add(baseUrl);
|
||||||
return baseUrl == AppConfig.lanBaseUrl;
|
active++;
|
||||||
},
|
if (active > maxActive) maxActive = active;
|
||||||
);
|
if (active == 3) allStarted.complete();
|
||||||
|
await allStarted.future;
|
||||||
|
active--;
|
||||||
|
return baseUrl == AppConfig.lanBaseUrl ||
|
||||||
|
baseUrl == AppConfig.devHostBaseUrl;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
expect(attempts, [AppConfig.lanBaseUrl]);
|
await probing.timeout(const Duration(seconds: 1));
|
||||||
expect(AppConfig.apiBaseUrl, AppConfig.lanBaseUrl);
|
|
||||||
});
|
expect(attempts, [
|
||||||
|
AppConfig.lanBaseUrl,
|
||||||
|
AppConfig.devHostBaseUrl,
|
||||||
|
AppConfig.publicBaseUrl,
|
||||||
|
]);
|
||||||
|
expect(maxActive, 3);
|
||||||
|
expect(AppConfig.apiBaseUrl, AppConfig.lanBaseUrl);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('should_probe_loopback_dev_host_then_public_in_order', () async {
|
test('should_probe_loopback_dev_host_then_public_in_order', () async {
|
||||||
final attempts = <String>[];
|
final attempts = <String>[];
|
||||||
@@ -125,7 +150,12 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(attempts, [custom]);
|
expect(attempts, [
|
||||||
|
custom,
|
||||||
|
AppConfig.lanBaseUrl,
|
||||||
|
AppConfig.devHostBaseUrl,
|
||||||
|
AppConfig.publicBaseUrl,
|
||||||
|
]);
|
||||||
expect(AppConfig.apiBaseUrl, custom);
|
expect(AppConfig.apiBaseUrl, custom);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:fruitshorts/core/config/app_config.dart';
|
||||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||||
|
|
||||||
import '../support/fake_http_adapter.dart';
|
import '../support/fake_http_adapter.dart';
|
||||||
@@ -18,63 +19,87 @@ void main() {
|
|||||||
final adapter = FakeHttpAdapter(
|
final adapter = FakeHttpAdapter(
|
||||||
(_) async => FakeHttpResponse(envelope({'id': 7})),
|
(_) async => FakeHttpResponse(envelope({'id': 7})),
|
||||||
);
|
);
|
||||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
|
final client = ApiClient(
|
||||||
|
baseUrl: 'http://127.0.0.1:8081',
|
||||||
|
adapter: adapter,
|
||||||
|
);
|
||||||
|
|
||||||
final data = await client.get('/app/dramas/7');
|
final data = await client.get('/app/dramas/7');
|
||||||
expect((data as Map)['id'], 7);
|
expect((data as Map)['id'], 7);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should_throw_api_exception_with_server_message_when_envelope_code_is_not_zero', () async {
|
test(
|
||||||
|
'should_throw_api_exception_with_server_message_when_envelope_code_is_not_zero',
|
||||||
|
() async {
|
||||||
|
final adapter = FakeHttpAdapter(
|
||||||
|
(_) async =>
|
||||||
|
FakeHttpResponse(envelope(null, code: 40001, message: '邮箱格式不正确')),
|
||||||
|
);
|
||||||
|
final client = ApiClient(
|
||||||
|
baseUrl: 'http://127.0.0.1:8081',
|
||||||
|
adapter: adapter,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
client.post('/app/auth/register', body: {'target': 'bad'}),
|
||||||
|
throwsA(
|
||||||
|
isA<ApiException>()
|
||||||
|
.having((e) => e.code, 'code', 40001)
|
||||||
|
.having((e) => e.message, 'message', '邮箱格式不正确'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'should_map_to_api_exception_with_http_status_when_body_has_no_envelope',
|
||||||
|
() async {
|
||||||
|
final adapter = FakeHttpAdapter(
|
||||||
|
(_) async => FakeHttpResponse('oops', 502),
|
||||||
|
);
|
||||||
|
final client = ApiClient(
|
||||||
|
baseUrl: 'http://127.0.0.1:8081',
|
||||||
|
adapter: adapter,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
client.get('/app/home/feed'),
|
||||||
|
throwsA(isA<ApiException>().having((e) => e.code, 'code', 502)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'should_send_bearer_token_and_device_header_when_both_are_available',
|
||||||
|
() async {
|
||||||
|
final adapter = FakeHttpAdapter(
|
||||||
|
(_) async => FakeHttpResponse(envelope(null)),
|
||||||
|
);
|
||||||
|
final client = ApiClient(
|
||||||
|
baseUrl: 'http://127.0.0.1:8081',
|
||||||
|
adapter: adapter,
|
||||||
|
accessTokenReader: () => 'token-1',
|
||||||
|
deviceIdReader: () async => 'device-9',
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.get('/app/me');
|
||||||
|
|
||||||
|
final request = adapter.requests.single;
|
||||||
|
expect(request.headers['Authorization'], 'Bearer token-1');
|
||||||
|
expect(request.headers['X-Device-Id'], 'device-9');
|
||||||
|
expect(request.headers['Accept'], 'application/json');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('should_put_query_map_into_request_uri_when_query_is_given', () async {
|
||||||
final adapter = FakeHttpAdapter(
|
final adapter = FakeHttpAdapter(
|
||||||
(_) async => FakeHttpResponse(
|
(_) async => FakeHttpResponse(envelope(null)),
|
||||||
envelope(null, code: 40001, message: '邮箱格式不正确'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
|
|
||||||
|
|
||||||
await expectLater(
|
|
||||||
client.post('/app/auth/register', body: {'target': 'bad'}),
|
|
||||||
throwsA(
|
|
||||||
isA<ApiException>()
|
|
||||||
.having((e) => e.code, 'code', 40001)
|
|
||||||
.having((e) => e.message, 'message', '邮箱格式不正确'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should_map_to_api_exception_with_http_status_when_body_has_no_envelope', () async {
|
|
||||||
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse('oops', 502));
|
|
||||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
|
|
||||||
|
|
||||||
await expectLater(
|
|
||||||
client.get('/app/home/feed'),
|
|
||||||
throwsA(
|
|
||||||
isA<ApiException>().having((e) => e.code, 'code', 502),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should_send_bearer_token_and_device_header_when_both_are_available', () async {
|
|
||||||
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse(envelope(null)));
|
|
||||||
final client = ApiClient(
|
final client = ApiClient(
|
||||||
baseUrl: 'http://127.0.0.1:8081',
|
baseUrl: 'http://127.0.0.1:8081',
|
||||||
adapter: adapter,
|
adapter: adapter,
|
||||||
accessTokenReader: () => 'token-1',
|
|
||||||
deviceIdReader: () async => 'device-9',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await client.get('/app/me');
|
|
||||||
|
|
||||||
final request = adapter.requests.single;
|
|
||||||
expect(request.headers['Authorization'], 'Bearer token-1');
|
|
||||||
expect(request.headers['X-Device-Id'], 'device-9');
|
|
||||||
expect(request.headers['Accept'], 'application/json');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should_put_query_map_into_request_uri_when_query_is_given', () async {
|
|
||||||
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse(envelope(null)));
|
|
||||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
|
|
||||||
|
|
||||||
await client.get('/app/search', query: {'keyword': '短剧', 'page': '2'});
|
await client.get('/app/search', query: {'keyword': '短剧', 'page': '2'});
|
||||||
|
|
||||||
final uri = adapter.requests.single.uri;
|
final uri = adapter.requests.single.uri;
|
||||||
@@ -86,7 +111,8 @@ void main() {
|
|||||||
test('should_skip_refresh_when_call_opts_out_of_auth_retry', () async {
|
test('should_skip_refresh_when_call_opts_out_of_auth_retry', () async {
|
||||||
var refreshed = false;
|
var refreshed = false;
|
||||||
final adapter = FakeHttpAdapter(
|
final adapter = FakeHttpAdapter(
|
||||||
(_) async => FakeHttpResponse(envelope(null, code: 401, message: '登录已过期')),
|
(_) async =>
|
||||||
|
FakeHttpResponse(envelope(null, code: 401, message: '登录已过期')),
|
||||||
);
|
);
|
||||||
final client = ApiClient(
|
final client = ApiClient(
|
||||||
baseUrl: 'http://127.0.0.1:8081',
|
baseUrl: 'http://127.0.0.1:8081',
|
||||||
@@ -98,10 +124,31 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expectLater(
|
await expectLater(
|
||||||
client.post('/app/auth/refresh', body: {'refreshToken': 'r1'}, skipAuthRetry: true),
|
client.post(
|
||||||
|
'/app/auth/refresh',
|
||||||
|
body: {'refreshToken': 'r1'},
|
||||||
|
skipAuthRetry: true,
|
||||||
|
),
|
||||||
throwsA(isA<ApiException>().having((e) => e.code, 'code', 401)),
|
throwsA(isA<ApiException>().having((e) => e.code, 'code', 401)),
|
||||||
);
|
);
|
||||||
expect(refreshed, isFalse);
|
expect(refreshed, isFalse);
|
||||||
expect(adapter.requests, hasLength(1));
|
expect(adapter.requests, hasLength(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'should_use_probed_endpoint_when_client_was_created_before_probe',
|
||||||
|
() async {
|
||||||
|
const probedBaseUrl = 'http://probed.test:8081';
|
||||||
|
final adapter = FakeHttpAdapter(
|
||||||
|
(_) async => FakeHttpResponse(envelope(null)),
|
||||||
|
);
|
||||||
|
final client = ApiClient(adapter: adapter);
|
||||||
|
|
||||||
|
AppConfig.applyRuntimeBaseUrl(probedBaseUrl);
|
||||||
|
addTearDown(AppConfig.clearRuntimeBaseUrl);
|
||||||
|
await client.get('/app/home/feed');
|
||||||
|
|
||||||
|
expect(adapter.requests.single.uri.origin, probedBaseUrl);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user