Files
plezy/test/services/plex_home_retry_test.dart
T
edde746 7437b43207 fix(plex): validate a failover candidate before switching the live endpoint
A transient GET failure on a healthy endpoint could park the client on an
unreachable fallback (e.g. the server host's Docker bridge gateway, which
plex.tv advertises as a local connection) for a full connect timeout, failing
every request in flight during that window (log bbr90).

The cascade now probes each candidate with an unauthenticated /identity
request under the discovery-race budget and only switches when it answers as
the expected server, mirroring the Jellyfin trust gate. Unreachable-looking
private IPv4 candidates stay in the list — a client on the server host can
legitimately reach them, so reachability is probed, not inferred.
2026-08-08 12:18:32 +02:00

484 lines
17 KiB
Dart

import 'dart:async';
import 'package:plezy/media/ids.dart';
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/utils/active_client_scope.dart';
typedef _RequestHandler = Future<http.StreamedResponse> Function(http.BaseRequest request);
class _SequenceClient extends http.BaseClient {
_SequenceClient(this._handlers);
final List<_RequestHandler> _handlers;
final requests = <http.BaseRequest>[];
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
requests.add(request);
if (_handlers.isEmpty) {
throw StateError('Unexpected request: ${request.url}');
}
return _handlers.removeAt(0)(request);
}
}
void main() {
group('PlexConfig language headers', () {
test('includes Plex language headers when configured', () {
final config = PlexConfig(
baseUrl: 'http://server:32400',
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
languageCode: 'fr',
);
expect(config.headers['Accept-Language'], 'fr');
expect(config.headers['X-Plex-Language'], 'fr');
});
test('copyWith preserves language headers when refreshing the token', () {
final config = PlexConfig(
baseUrl: 'http://server:32400',
token: 'old-token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
languageCode: 'es',
).copyWith(token: 'new-token');
expect(config.headers['X-Plex-Token'], 'new-token');
expect(config.headers['Accept-Language'], 'es');
expect(config.headers['X-Plex-Language'], 'es');
});
});
group('PlexClient home hub retries', () {
test('fetchGlobalHubs retries a first-attempt connection error', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
addTearDown(db.close);
final httpClient = _SequenceClient([
(_) async => throw http.ClientException('connection reset on cold Plex start'),
(_) async => _jsonResponse(_globalHubsPayload()),
]);
final client = PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'http://server:32400',
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
);
addTearDown(client.close);
final hubs = await client.fetchGlobalHubs(limit: 12);
expect(hubs, hasLength(1));
expect(hubs.single.title, 'Recently Added Movies');
expect(hubs.single.items.single.title, 'Movie A');
expect(httpClient.requests, hasLength(2));
expect(httpClient.requests.map((r) => r.url.path), everyElement('/hubs'));
expect(httpClient.requests.map((r) => r.url.queryParameters['count']), everyElement('12'));
});
test('fetchGlobalHubs does not replay a hub row that timed out', () async {
// `Client.send` resolves on response headers, so a hub timeout usually
// means the server is still working on the query. Replaying it made the
// server start over on a shorter budget — the #1784 cold-start stall.
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
addTearDown(db.close);
final httpClient = _SequenceClient([
(_) async => throw TimeoutException('server still building the hub'),
(_) async => _jsonResponse(_globalHubsPayload()),
]);
final client = PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'http://server:32400',
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
);
addTearDown(client.close);
// `_fetchHubs` degrades a failed row to empty rather than sinking home.
expect(await client.fetchGlobalHubs(limit: 12), isEmpty);
expect(httpClient.requests, hasLength(1));
});
test('fetchGlobalHubs sends configured Plex language headers', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
addTearDown(db.close);
final httpClient = _SequenceClient([(_) async => _jsonResponse(_globalHubsPayload())]);
final client = PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'http://server:32400',
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
languageCode: 'fr',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
);
addTearDown(client.close);
await client.fetchGlobalHubs(limit: 12);
expect(httpClient.requests.single.headers['Accept-Language'], 'fr');
expect(httpClient.requests.single.headers['X-Plex-Language'], 'fr');
});
test('applyLanguageUpdate refreshes headers on the live HTTP client', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
addTearDown(db.close);
final httpClient = _SequenceClient([
(_) async => _jsonResponse(_globalHubsPayload()),
(_) async => _jsonResponse(_globalHubsPayload()),
]);
final client = PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'http://server:32400',
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
languageCode: 'en',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
);
addTearDown(client.close);
await client.fetchGlobalHubs(limit: 12);
client.applyLanguageUpdate('fr');
await client.fetchGlobalHubs(limit: 12);
expect(httpClient.requests[0].headers['Accept-Language'], 'en');
expect(httpClient.requests[0].headers['X-Plex-Language'], 'en');
expect(httpClient.requests[1].headers['Accept-Language'], 'fr');
expect(httpClient.requests[1].headers['X-Plex-Language'], 'fr');
});
test('fetchGlobalHubs retries a connection error without switching Plex endpoints', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
addTearDown(db.close);
const primary = 'http://primary:32400';
const fallback = 'http://fallback:32400';
final httpClient = _SequenceClient([
(_) async => throw http.ClientException('connection reset'),
(_) async => _jsonResponse(_globalHubsPayload()),
]);
final client = PlexClient.forTesting(
config: PlexConfig(
baseUrl: primary,
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
prioritizedEndpoints: const [primary, fallback],
);
addTearDown(client.close);
final hubs = await client.fetchGlobalHubs(limit: 12);
expect(hubs, hasLength(1));
expect(client.config.baseUrl, primary);
expect(httpClient.requests.map((r) => r.url.origin), everyElement(primary));
});
test('resets live base URL after fallback endpoint is exhausted', () async {
const primary = 'http://primary:32400';
const fallback = 'http://fallback:32400';
final httpClient = _SequenceClient([
(_) async => throw TimeoutException('primary down'),
(_) async => throw TimeoutException('fallback down'),
(_) async => _jsonResponse({
'MediaContainer': {'machineIdentifier': 'server-id'},
}),
]);
final client = PlexClient.forTesting(
config: PlexConfig(
baseUrl: primary,
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
prioritizedEndpoints: const [primary, fallback],
// The candidate must validate for the cascade to reach the
// authenticated retry whose failure this test pins.
endpointProbeHttpClientFactory: () => MockClient(
(_) async => http.Response(
jsonEncode({
'MediaContainer': {'machineIdentifier': 'server-id'},
}),
200,
headers: {'content-type': 'application/json'},
),
),
);
addTearDown(client.close);
await expectLater(client.getServerIdentity(), throwsA(isA<Object>()));
expect(client.config.baseUrl, primary);
expect(httpClient.requests.map((r) => r.url.origin), [primary, fallback]);
await client.getServerIdentity();
expect(httpClient.requests.map((r) => r.url.origin), [primary, fallback, primary]);
});
test('fetchGlobalHubs uses promoted hub endpoint advertised by media providers', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
addTearDown(db.close);
final httpClient = _SequenceClient([
(_) async => _jsonResponse(_mediaProvidersPayload()),
(_) async => _jsonResponse(_globalHubsPayload()),
]);
final client = await PlexClient.create(
PlexConfig(
baseUrl: 'http://server:32400',
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
seedTranscoderVideoSupport: true,
);
addTearDown(client.close);
final hubs = await client.fetchGlobalHubs(limit: 12);
expect(hubs, hasLength(1));
expect(hubs.single.title, 'Recently Added Movies');
expect(httpClient.requests.map((r) => r.url.path), ['/media/providers', '/hubs/promoted']);
expect(httpClient.requests.last.url.queryParameters['count'], '12');
});
test('fetchContinueWatching uses advertised provider feature endpoint', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
addTearDown(db.close);
final httpClient = _SequenceClient([
(_) async => _jsonResponse(_mediaProvidersPayload()),
(_) async => _jsonResponse(_continueWatchingPayload()),
]);
final client = await PlexClient.create(
PlexConfig(
baseUrl: 'http://server:32400',
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
seedTranscoderVideoSupport: true,
);
addTearDown(client.close);
final items = await client.fetchContinueWatching(count: 21);
expect(items, hasLength(1));
expect(items.single.title, 'Movie A');
expect(httpClient.requests.map((r) => r.url.path), ['/media/providers', '/hubs/continueWatching']);
expect(httpClient.requests.last.url.queryParameters['count'], '21');
expect(httpClient.requests.last.url.queryParameters['includeGuids'], '1');
expect(httpClient.requests.last.url.queryParameters.containsKey('identifier'), isFalse);
});
test('fetchContinueWatching omits count when uncapped', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
addTearDown(db.close);
final httpClient = _SequenceClient([(_) async => _jsonResponse(_continueWatchingPayload())]);
final client = PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'http://server:32400',
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
);
addTearDown(client.close);
final items = await client.fetchContinueWatching(count: null);
expect(items, hasLength(1));
expect(items.single.title, 'Movie A');
expect(httpClient.requests.single.url.path, '/hubs');
expect(httpClient.requests.single.url.queryParameters['identifier'], 'home.continue,home.ondeck');
expect(httpClient.requests.single.url.queryParameters.containsKey('count'), isFalse);
expect(httpClient.requests.single.url.queryParameters['includeGuids'], '1');
});
test('fetchLibraryHubs retries a connection error without switching Plex endpoints', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
addTearDown(db.close);
const primary = 'http://primary:32400';
const fallback = 'http://fallback:32400';
final httpClient = _SequenceClient([
(_) async => throw http.ClientException('connection reset'),
(_) async => _jsonResponse(_globalHubsPayload()),
]);
final client = PlexClient.forTesting(
config: PlexConfig(
baseUrl: primary,
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
prioritizedEndpoints: const [primary, fallback],
);
addTearDown(client.close);
final hubs = await client.fetchLibraryHubs('4', libraryName: 'Movies', limit: 12);
expect(hubs, hasLength(1));
expect(hubs.single.items.single.libraryId, '4');
expect(hubs.single.items.single.libraryTitle, 'Movies');
expect(client.config.baseUrl, primary);
expect(httpClient.requests, hasLength(2));
expect(httpClient.requests.map((r) => r.url.origin), everyElement(primary));
expect(httpClient.requests.map((r) => r.url.path), everyElement('/hubs/sections/4'));
expect(httpClient.requests.map((r) => r.url.queryParameters['count']), everyElement('12'));
});
});
}
Future<http.StreamedResponse> _jsonResponse(Map<String, dynamic> body) async {
return http.StreamedResponse(
Stream.value(utf8.encode(jsonEncode(body))),
200,
headers: {'content-type': 'application/json'},
);
}
Map<String, dynamic> _globalHubsPayload() => {
'MediaContainer': {
'Hub': [
{
'key': '/hubs/movie.recentlyAdded',
'title': 'Recently Added Movies',
'type': 'movie',
'hubIdentifier': 'movie.recentlyAdded.1',
'size': 1,
'Metadata': [
{'ratingKey': '1', 'type': 'movie', 'title': 'Movie A'},
],
},
],
},
};
Map<String, dynamic> _continueWatchingPayload() => {
'MediaContainer': {
'Hub': [
{
'key': '/hubs/home/continueWatching',
'title': 'Continue Watching',
'type': 'mixed',
'hubIdentifier': 'home.continue',
'size': 1,
'more': false,
'Metadata': [
{'ratingKey': '1', 'type': 'movie', 'title': 'Movie A'},
],
},
],
},
};
Map<String, dynamic> _mediaProvidersPayload() => {
'MediaContainer': {
'MediaProvider': [
{
'identifier': 'com.plexapp.plugins.library',
'Feature': [
{
'type': 'content',
'Directory': [
{'title': 'Home', 'hubKey': '/hubs'},
{
'id': '1',
'key': '/library/sections/1',
'hubKey': '/hubs/sections/1',
'type': 'movie',
'title': 'Movies',
},
],
},
{'type': 'promoted', 'key': '/hubs/promoted'},
{'type': 'continuewatching', 'key': '/hubs/continueWatching'},
],
},
],
},
};