diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index b3f6c2d9..039d168e 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -1139,6 +1139,9 @@ class MultiServerManager { }); } + /// Fire-and-forget safe: both backends' `checkHealth` catch every failure + /// and fold it into a [HealthStatus], and the scheduled reconnection guards + /// its own errors — this future must never complete with one. Future _verifyServerEndpointsExhausted(ServerId serverId) async { final client = _clients[serverId]; if (client == null || !_endpointHealthChecks.add(serverId)) return; @@ -1193,6 +1196,11 @@ class MultiServerManager { Future debugVerifyServerEndpointsExhaustedForTesting(ServerId serverId) => _verifyServerEndpointsExhausted(serverId); + /// Entry point matching production exhaustion wiring (debounce + the + /// in-flight-verification guard), for tests driving the full retry loop. + @visibleForTesting + void debugTriggerEndpointsExhaustedForTesting(ServerId serverId) => _onServerEndpointsExhausted(serverId); + /// Disconnect all servers void disconnectAll() { appLogger.i('Disconnecting all servers'); diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index d283246f..0adba63c 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -43,12 +44,15 @@ JellyfinClient _jellyfinClient(String userId) => testJellyfinClient(connection: // - `disconnectAll` / `dispose` lifecycle (no connectivity sub started, so // this verifies the no-op path for the subscription cancel) // +// The Jellyfin exhaustion path IS covered ('endpoint exhaustion verification' +// group): the health-probe confirmation, offline flip + reconnection, and the +// debounce-driven retry loop, via the registered fake Jellyfin client. +// // What is NOT covered here (would need a fake PlexClient factory): // - `addServer` success path // - `connectToAllServers` outcome map // - `checkServerHealth` health-probe sweep // - `_reoptimizeServer` endpoint promotion -// - `_onServerEndpointsExhausted` debounce → reconnect // - `startNetworkMonitoring` connectivity-listener path void main() { @@ -193,6 +197,80 @@ void main() { {'jf-machine': false}, ]); }); + + test('confirmed-offline probe publishes offline once and schedules reconnection', () async { + final manager = MultiServerManager(); + addTearDown(manager.dispose); + var probes = 0; + final client = testJellyfinClient( + connection: _jellyfinConnection('user-a'), + handler: (req) async { + if (req.url.path == '/Users/Me') probes++; + return http.Response('', 500); + }, + ); + manager.debugRegisterJellyfinClientForTesting(client); + + final emitted = >[]; + final sub = manager.statusStream.listen(emitted.add); + addTearDown(sub.cancel); + + await manager.debugVerifyServerEndpointsExhaustedForTesting(ServerId('jf-machine')); + // The scheduled reconnection runs unawaited — let it finish. + await pumpEventQueue(); + + expect(manager.isServerOnline(ServerId('jf-machine')), isFalse); + expect(probes, 2, reason: 'confirmed exhaustion schedules the backend reconnection probe'); + expect(emitted, [ + {'jf-machine': false}, + ], reason: 'the reconnection probe repeating the offline verdict must not re-publish it'); + }); + + test('probe-raised exhaustion re-arms the retry loop and recovers when the server returns', () { + fakeAsync((async) { + final manager = MultiServerManager(); + var healthy = false; + final client = testJellyfinClient( + connection: _jellyfinConnection('user-a'), + handler: (_) async => healthy + ? http.Response( + '{"Policy":{"IsAdministrator":false}}', + 200, + headers: {'content-type': 'application/json'}, + ) + : http.Response('', 500), + // Production wiring: the probe's own failed GET re-raises exhaustion. + onAllEndpointsExhausted: () => manager.debugTriggerEndpointsExhaustedForTesting(ServerId('jf-machine')), + ); + manager.debugRegisterJellyfinClientForTesting(client); + + final emitted = >[]; + final sub = manager.statusStream.listen(emitted.add); + + // A failed content GET raises exhaustion → debounce → probe confirms + // offline. Exhaustion raised DURING the verification is swallowed by + // the in-flight guard; the reconnection probe's failure fires after + // the guard clears and re-arms the debounce. + manager.debugTriggerEndpointsExhaustedForTesting(ServerId('jf-machine')); + async.elapse(const Duration(seconds: 5)); + async.flushMicrotasks(); + expect(manager.isServerOnline(ServerId('jf-machine')), isFalse); + + // Server recovers: the self-re-armed loop flips it back online with + // no external trigger — offline retry must survive the guard. + healthy = true; + async.elapse(const Duration(seconds: 6)); + async.flushMicrotasks(); + expect(manager.isServerOnline(ServerId('jf-machine')), isTrue); + expect(emitted, [ + {'jf-machine': false}, + {'jf-machine': true}, + ]); + + sub.cancel(); + manager.dispose(); + }); + }); }); group('refreshTokensForProfile', () {