diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index d73a1fe..a4b02ad 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -28,6 +28,12 @@ class AppConfig { static String _runtimeBaseUrl = ''; + static Future? _ready; + + /// 启动地址探测完成信号;默认已完成,避免影响既有测试与调用方。 + static Future get ready => _ready ?? Future.value(); + static set ready(Future value) => _ready = value; + static const Duration _probeTimeout = Duration(seconds: 2); static const List _probeCandidates = [ lanBaseUrl, @@ -85,10 +91,15 @@ class AppConfig { for (final candidate in _probeCandidates) if (candidate != _runtimeBaseUrl) candidate, ]; - for (final candidate in candidates) { - final result = probe == null - ? await _probeBaseUrl(candidate) - : await _runInjectedProbe(candidate, probe); + final results = await Future.wait([ + for (final candidate in candidates) + probe == null + ? _probeBaseUrl(candidate) + : _runInjectedProbe(candidate, probe), + ]); + for (var index = 0; index < candidates.length; index++) { + final candidate = candidates[index]; + final result = results[index]; if (kDebugMode) { final status = result.ok ? 'ok' diff --git a/lib/core/data/remote/api_client.dart b/lib/core/data/remote/api_client.dart index 906a66d..6f934f1 100644 --- a/lib/core/data/remote/api_client.dart +++ b/lib/core/data/remote/api_client.dart @@ -21,7 +21,7 @@ class ApiClient { this.tokenRefresher, this.onUnauthorized, this.deviceIdReader, - }) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl { + }) : _fixedBaseUrl = baseUrl { _dio = Dio( BaseOptions( baseUrl: this.baseUrl, @@ -45,9 +45,12 @@ class ApiClient { } } - final String baseUrl; + final String? _fixedBaseUrl; late final Dio _dio; + /// 当前请求使用的服务地址;未显式指定时跟随启动探测结果。 + String get baseUrl => _fixedBaseUrl ?? AppConfig.apiBaseUrl; + /// 取当前 access token(未登录返回 null) final String? Function()? accessTokenReader; @@ -110,6 +113,7 @@ class ApiClient { bool retried = false, bool skipAuthRetry = false, }) async { + _syncBaseUrl(); final disableAuthRetry = skipAuthRetry || tokenRefresher == null; try { final response = await _dio.request( @@ -150,5 +154,10 @@ class ApiClient { } } + void _syncBaseUrl() { + if (_fixedBaseUrl != null) return; + _dio.options.baseUrl = AppConfig.apiBaseUrl; + } + void close() => _dio.close(force: true); } diff --git a/lib/core/data/services/catalog_controller.dart b/lib/core/data/services/catalog_controller.dart index 3766f2e..dc742d8 100644 --- a/lib/core/data/services/catalog_controller.dart +++ b/lib/core/data/services/catalog_controller.dart @@ -35,6 +35,7 @@ class CatalogController extends ChangeNotifier { debugError = null; notifyListeners(); try { + await AppConfig.ready; feed = await repository.fetchHomeFeed(); } on ApiException catch (exception) { error = exception.message; diff --git a/lib/main.dart b/lib/main.dart index bb45154..74bb23a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,6 @@ Future main() async { SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]), ServerEndpoint.load(), ]); - await AppConfig.probeAndApplyBaseUrl(); + AppConfig.ready = AppConfig.probeAndApplyBaseUrl(); runApp(const LumaDramaApp()); } diff --git a/test/catalog/catalog_controller_test.dart b/test/catalog/catalog_controller_test.dart index d5fbf83..1654c61 100644 --- a/test/catalog/catalog_controller_test.dart +++ b/test/catalog/catalog_controller_test.dart @@ -1,61 +1,102 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; +import 'package:fruitshorts/core/config/app_config.dart'; void main() { - test('should_distinguish_empty_catalog_from_load_failure_when_retry_succeeds', () async { + setUp(() { + AppConfig.clearRuntimeBaseUrl(); + AppConfig.ready = Future.value(); + }); + + tearDown(() { + AppConfig.clearRuntimeBaseUrl(); + AppConfig.ready = Future.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(); + AppConfig.ready = ready.future; final repository = ScriptedCatalogRepository(); final controller = CatalogController(repository); - expect(controller.settled, isFalse); - await controller.load(); + final loading = controller.load(); + await Future.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.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( @@ -81,9 +122,13 @@ class ScriptedCatalogRepository implements CatalogRepository { bool fail = false; HomeFeed feed = const HomeFeed(); int detailCalls = 0; + int homeCalls = 0; + String? requestBaseUrl; @override Future fetchHomeFeed() async { + homeCalls++; + requestBaseUrl = AppConfig.apiBaseUrl; if (fail) throw StateError('offline'); return feed; } @@ -96,5 +141,4 @@ class ScriptedCatalogRepository implements CatalogRepository { detailCalls++; return _detail(dramaId); } - } diff --git a/test/config/server_endpoint_test.dart b/test/config/server_endpoint_test.dart index e0f7b08..7f640f6 100644 --- a/test/config/server_endpoint_test.dart +++ b/test/config/server_endpoint_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; @@ -11,9 +13,13 @@ void main() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); AppConfig.clearRuntimeBaseUrl(); + AppConfig.ready = Future.value(); }); - tearDown(AppConfig.clearRuntimeBaseUrl); + tearDown(() { + AppConfig.clearRuntimeBaseUrl(); + AppConfig.ready = Future.value(); + }); test('should_normalize_http_urls_and_reject_invalid_values', () { expect( @@ -60,20 +66,39 @@ void main() { expect(AppConfig.devHostBaseUrl, 'http://192.168.31.187:8081'); }); - test('should_keep_loopback_priority_when_probe_succeeds', () async { - final attempts = []; + test( + 'should_probe_all_candidates_in_parallel_before_applying_priority', + () async { + final attempts = []; + var active = 0; + var maxActive = 0; + final allStarted = Completer(); - await AppConfig.probeAndApplyBaseUrl( - fallbackBaseUrl: AppConfig.devHostBaseUrl, - probe: (baseUrl) async { - attempts.add(baseUrl); - return baseUrl == AppConfig.lanBaseUrl; - }, - ); + final probing = AppConfig.probeAndApplyBaseUrl( + fallbackBaseUrl: AppConfig.devHostBaseUrl, + probe: (baseUrl) async { + attempts.add(baseUrl); + 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]); - expect(AppConfig.apiBaseUrl, AppConfig.lanBaseUrl); - }); + await probing.timeout(const Duration(seconds: 1)); + + 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 { final attempts = []; @@ -125,7 +150,12 @@ void main() { }, ); - expect(attempts, [custom]); + expect(attempts, [ + custom, + AppConfig.lanBaseUrl, + AppConfig.devHostBaseUrl, + AppConfig.publicBaseUrl, + ]); expect(AppConfig.apiBaseUrl, custom); }); diff --git a/test/core/api_client_test.dart b/test/core/api_client_test.dart index 2b69055..b8f12c1 100644 --- a/test/core/api_client_test.dart +++ b/test/core/api_client_test.dart @@ -4,6 +4,7 @@ import 'dart:convert'; 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 '../support/fake_http_adapter.dart'; @@ -18,63 +19,87 @@ void main() { final adapter = FakeHttpAdapter( (_) 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'); 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() + .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().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( - (_) async => FakeHttpResponse( - envelope(null, code: 40001, message: '邮箱格式不正确'), - ), + (_) async => FakeHttpResponse(envelope(null)), ); - final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter); - - await expectLater( - client.post('/app/auth/register', body: {'target': 'bad'}), - throwsA( - isA() - .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().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((_) 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'}); final uri = adapter.requests.single.uri; @@ -86,7 +111,8 @@ void main() { test('should_skip_refresh_when_call_opts_out_of_auth_retry', () async { var refreshed = false; final adapter = FakeHttpAdapter( - (_) async => FakeHttpResponse(envelope(null, code: 401, message: '登录已过期')), + (_) async => + FakeHttpResponse(envelope(null, code: 401, message: '登录已过期')), ); final client = ApiClient( baseUrl: 'http://127.0.0.1:8081', @@ -98,10 +124,31 @@ void main() { ); await expectLater( - client.post('/app/auth/refresh', body: {'refreshToken': 'r1'}, skipAuthRetry: true), + client.post( + '/app/auth/refresh', + body: {'refreshToken': 'r1'}, + skipAuthRetry: true, + ), throwsA(isA().having((e) => e.code, 'code', 401)), ); expect(refreshed, isFalse); 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); + }, + ); }