fix(runtime): harden application service boundaries

This commit is contained in:
edde746
2026-07-24 03:46:46 +02:00
parent 658da37b48
commit e0bf66eea8
309 changed files with 32574 additions and 4369 deletions
@@ -0,0 +1,82 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plezy/mpv/player/player.dart';
import 'package:plezy/mpv/player/player_state.dart';
import 'package:plezy/services/ambient_lighting_service.dart';
import '../test_helpers/io_fakes.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late PathProviderPlatform originalPathProvider;
late Directory temporaryRoot;
setUp(() {
originalPathProvider = PathProviderPlatform.instance;
temporaryRoot = Directory.systemTemp.createTempSync('plezy_ambient_test_');
PathProviderPlatform.instance = FakePathProvider(temporaryRoot);
});
tearDown(() {
PathProviderPlatform.instance = originalPathProvider;
temporaryRoot.deleteSync(recursive: true);
});
test('resize property failure is contained while ambient lighting remains enabled', () async {
final player = _AmbientPlayer();
final service = AmbientLightingService(player);
await service.enable(16 / 9, 4 / 3);
expect(service.isEnabled, isTrue);
player.setPropertyError = StateError('rejected');
service.updateOutputAspect(2);
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(service.isEnabled, isTrue);
expect(player.propertyWrites.last, ('video-aspect-override', '2.0'));
});
test('valid resize property write is applied once', () async {
final player = _AmbientPlayer();
final service = AmbientLightingService(player);
await service.enable(16 / 9, 4 / 3);
final writesBeforeResize = player.propertyWrites.length;
service.updateOutputAspect(2);
await Future<void>.delayed(Duration.zero);
expect(player.propertyWrites, hasLength(writesBeforeResize + 1));
expect(player.propertyWrites.last, ('video-aspect-override', '2.0'));
});
}
class _AmbientPlayer implements Player {
final List<(String, String)> propertyWrites = [];
Object? setPropertyError;
@override
PlayerState get state => const PlayerState();
@override
String get playerType => 'mpv';
@override
Future<void> command(List<String> command) async {}
@override
Future<void> setProperty(String name, String value) {
propertyWrites.add((name, value));
final error = setPropertyError;
if (error != null) return Future<void>.error(error);
return Future<void>.value();
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -0,0 +1,263 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/companion_remote/lan_discovery_service.dart';
import 'package:plezy/services/companion_remote/remote_auth_context.dart';
import 'package:plezy/services/companion_remote/remote_auth_service.dart';
void main() {
group('LanDiscoveryService', () {
test(
'publishes a changed normalized IP set for an existing host',
() async {
final context = _authContext(
id: 'context-a',
discoveryKey: List<int>.generate(32, (index) => index),
);
final listener = await _DiscoveryListener.start([context]);
try {
listener.sendBeacon(context: context, ips: const ['192.0.2.10']);
await _waitFor(() => listener.emissions.length == 1);
listener.sendBeacon(
context: context,
ips: const ['192.0.2.30', '10.0.0.30'],
);
await _waitFor(() => listener.emissions.length == 2);
final hosts = listener.emissions.last;
expect(hosts, hasLength(1));
final host = hosts.single;
expect(host.clientId, 'shared-client');
expect(host.authContextId, 'context-a');
expect(host.ips, ['10.0.0.30', '192.0.2.30']);
expect(
host.addresses,
unorderedEquals(['10.0.0.30:52100', '192.0.2.30:52100']),
);
expect(host.addresses, isNot(contains('192.0.2.10:52100')));
} finally {
await listener.close();
}
},
);
test(
'suppresses reordered IPs and publishes a platform-only change',
() async {
final context = _authContext(
id: 'context-a',
discoveryKey: List<int>.generate(32, (index) => index + 32),
);
final listener = await _DiscoveryListener.start([context]);
try {
listener.sendBeacon(
context: context,
platform: 'macOS',
ips: const ['192.0.2.40', '10.0.0.40'],
);
await _waitFor(() => listener.emissions.length == 1);
listener.sendBeacon(
context: context,
platform: 'macOS',
ips: const ['10.0.0.40', '192.0.2.40'],
);
listener.sendBeacon(
context: context,
platform: 'Android',
ips: const ['192.0.2.40', '10.0.0.40'],
);
await _waitFor(
() => listener.emissions.any(
(hosts) => hosts.single.platform == 'Android',
),
);
expect(listener.emissions, hasLength(2));
final hosts = listener.emissions.last;
expect(hosts, hasLength(1));
final host = hosts.single;
expect(host.clientId, 'shared-client');
expect(host.platform, 'Android');
expect(
host.addresses,
unorderedEquals(['10.0.0.40:52100', '192.0.2.40:52100']),
);
} finally {
await listener.close();
}
},
);
test(
'suppresses context-only churn and retains the usable context',
() async {
final firstContext = _authContext(
id: 'context-a',
discoveryKey: List<int>.generate(32, (index) => index + 64),
);
final secondContext = _authContext(
id: 'context-b',
discoveryKey: List<int>.generate(32, (index) => index + 96),
);
final listener = await _DiscoveryListener.start([
firstContext,
secondContext,
]);
try {
listener.sendBeacon(
context: firstContext,
name: 'Living Room',
ips: const ['192.0.2.50'],
);
await _waitFor(() => listener.emissions.length == 1);
listener.sendBeacon(
context: secondContext,
name: 'Living Room',
ips: const ['192.0.2.50'],
);
listener.sendBeacon(
context: secondContext,
name: 'Living Room TV',
ips: const ['192.0.2.50'],
);
await _waitFor(
() => listener.emissions.any(
(hosts) => hosts.single.name == 'Living Room TV',
),
);
expect(listener.emissions, hasLength(2));
final hosts = listener.emissions.last;
expect(hosts, hasLength(1));
expect(hosts.single.clientId, 'shared-client');
expect(hosts.single.name, 'Living Room TV');
expect(hosts.single.authContextId, 'context-a');
} finally {
await listener.close();
}
},
);
});
}
RemoteAuthContext _authContext({
required String id,
required List<int> discoveryKey,
}) {
return RemoteAuthContext(
id: id,
backend: 'plex',
connectionId: 'connection-$id',
homeSecret: List<int>.filled(32, 7),
discoveryKey: discoveryKey,
clientIdentifier: 'shared-client',
userUuid: 'user-$id',
allowedUserUuids: ['user-$id'],
);
}
class _DiscoveryListener {
_DiscoveryListener._({
required this.service,
required this.sender,
required this.subscription,
required this.emissions,
});
final LanDiscoveryService service;
final RawDatagramSocket sender;
final StreamSubscription<List<DiscoveredHost>> subscription;
final List<List<DiscoveredHost>> emissions;
static Future<_DiscoveryListener> start(
List<RemoteAuthContext> contexts,
) async {
final service = LanDiscoveryService();
final emissions = <List<DiscoveredHost>>[];
final subscription = service
.startListeningForContexts(contexts)
.listen(emissions.add);
final sender = await RawDatagramSocket.bind(
InternetAddress.loopbackIPv4,
0,
);
try {
await _waitFor(() => service.isListening);
return _DiscoveryListener._(
service: service,
sender: sender,
subscription: subscription,
emissions: emissions,
);
} catch (_) {
sender.close();
await subscription.cancel();
service.dispose();
rethrow;
}
}
void sendBeacon({
required RemoteAuthContext context,
required List<String> ips,
String name = 'Living Room',
String platform = 'macOS',
int port = 52100,
}) {
const version = 1;
final auth = RemoteAuthService.instance;
final homeHash = auth.computeDiscoveryTag(context.discoveryKey);
final hmac = auth.computeBeaconHmac(
discoveryKey: context.discoveryKey,
version: version,
homeHash: homeHash,
name: name,
platform: platform,
clientId: context.clientIdentifier,
port: port,
ips: ips,
);
final packet = utf8.encode(
jsonEncode({
'app': 'plezy',
'v': version,
'homeHash': homeHash,
'name': name,
'platform': platform,
'clientId': context.clientIdentifier,
'port': port,
'ips': ips,
'hmac': hmac,
}),
);
sender.send(
packet,
InternetAddress.loopbackIPv4,
LanDiscoveryService.discoveryPort,
);
}
Future<void> close() async {
sender.close();
await subscription.cancel();
service.dispose();
}
}
Future<void> _waitFor(bool Function() condition) async {
for (var attempt = 0; attempt < 100; attempt++) {
if (condition()) return;
await Future<void>.delayed(const Duration(milliseconds: 20));
}
fail('Timed out waiting for LAN discovery behavior');
}
@@ -17,7 +17,6 @@ import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/backend_client_fixtures.dart';
@@ -133,7 +132,7 @@ void main() {
final plexRequests = <Uri>[];
final jellyfinRequests = <Uri>[];
final plexClient = PlexClient.forTesting(
final plexClient = testPlexClient(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
@@ -196,7 +195,7 @@ void main() {
test('getOnDeckFromAllServers forwards preview limit to clients', () async {
final captured = <Uri>[];
final client = PlexClient.forTesting(
final client = testPlexClient(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
@@ -241,7 +240,7 @@ void main() {
});
test('getOnDeckFromAllServers filters hidden Plex continue-watching libraries', () async {
final client = PlexClient.forTesting(
final client = testPlexClient(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
@@ -296,7 +295,7 @@ void main() {
});
test('getOnDeckFromAllServers hides duplicate show entries by stable show ids', () async {
final client = PlexClient.forTesting(
final client = testPlexClient(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
@@ -378,7 +377,7 @@ void main() {
// reliably. The locally recorded play must decide the surviving card —
// and it must keep the winner in the group's original shelf slot,
// ahead of the unrelated movie sorted between the two episodes (#1492).
final client = PlexClient.forTesting(
final client = testPlexClient(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
@@ -462,7 +461,7 @@ void main() {
});
test('getOnDeckFromAllServers prefers a duplicate recorded by item key', () async {
final client = PlexClient.forTesting(
final client = testPlexClient(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
@@ -537,7 +536,7 @@ void main() {
});
test('getOnDeckFromAllServers keeps duplicate titles without stable ids', () async {
final client = PlexClient.forTesting(
final client = testPlexClient(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
@@ -868,7 +867,7 @@ void main() {
test('Plex home layout keeps promoted hubs instead of splitting by preview libraries', () async {
final captured = <Uri>[];
final client = PlexClient.forTesting(
final client = testPlexClient(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
@@ -943,7 +942,7 @@ void main() {
test('Plex home layout appends music library hubs the promoted endpoint excludes', () async {
final captured = <Uri>[];
final client = PlexClient.forTesting(
final client = testPlexClient(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
@@ -119,15 +119,44 @@ void main() {
final filePath = await service.localPath(ServerId('srv'), rawPath);
await File(filePath).writeAsString('<html>not an image</html>');
await service.downloadSingleArtwork(
ServerId('srv'),
DownloadArtworkSpec(localKey: artworkStorageKey(rawPath), url: 'https://example.test/logo.png'),
expect(
await service.downloadSingleArtwork(
ServerId('srv'),
DownloadArtworkSpec(localKey: artworkStorageKey(rawPath), url: 'https://example.test/logo.png'),
),
isTrue,
);
expect(await File(filePath).readAsBytes(), body);
expect(await service.existsUsable(ServerId('srv'), rawPath), isTrue);
});
test('artwork settlement reports HTTP and invalid-image failures', () async {
final settings = await SettingsService.getInstance();
final storage = DownloadStorageService.instance;
await storage.initialize(settings);
final missingService = DownloadArtworkService(
storageService: storage,
http: MediaServerHttpClient(client: FakeHttpClient(404, utf8.encode('not found'))),
);
final invalidService = DownloadArtworkService(
storageService: storage,
http: MediaServerHttpClient(client: FakeHttpClient(200, utf8.encode('<html>error</html>'))),
);
final missingSettled = await missingService.ensureArtworkSpecs(ServerId('srv'), const [
DownloadArtworkSpec(localKey: '/missing.jpg', url: 'https://example.test/missing.jpg'),
]);
final invalidSettled = await invalidService.ensureArtworkSpecs(ServerId('srv'), const [
DownloadArtworkSpec(localKey: '/invalid.jpg', url: 'https://example.test/invalid.jpg'),
]);
expect(missingSettled, isFalse);
expect(invalidSettled, isFalse);
expect(await missingService.existsUsable(ServerId('srv'), '/missing.jpg'), isFalse);
expect(await invalidService.existsUsable(ServerId('srv'), '/invalid.jpg'), isFalse);
});
test('downloadSingleArtwork serializes duplicate writes to the same local file', () async {
final settings = await SettingsService.getInstance();
final storage = DownloadStorageService.instance;
@@ -146,7 +175,7 @@ void main() {
await Future<void>.delayed(Duration.zero);
httpClient.release.complete();
await Future.wait([first, second]);
expect(await Future.wait([first, second]), everyElement(isTrue));
expect(httpClient.sends, 1);
expect(await service.existsUsable(ServerId('srv'), rawPath), isTrue);
File diff suppressed because it is too large Load Diff
@@ -115,16 +115,27 @@ void main() {
expect(display, customDir.path);
});
test('falls back to default when custom path is non-writable', () async {
final settings = await SettingsService.getInstance();
final regularFile = File(p.join(tmpRoot.path, 'not-a-directory'))..writeAsStringSync('blocking ancestor');
final blocked = p.join(regularFile.path, 'downloads');
await settings.write(SettingsService.customDownloadPathType, 'file');
await settings.write(SettingsService.customDownloadPath, blocked);
final dss = DownloadStorageService.instance;
await dss.initialize(settings);
final dir = await dss.getDownloadsDirectory();
expect(dir.existsSync(), isTrue);
expect(dir.path, p.join(tmpRoot.path, 'support', 'downloads'));
});
test(
'falls back to default when custom path is non-writable',
'resolves under POSIX chmod restrictions (environment-dependent smoke)',
() async {
final settings = await SettingsService.getInstance();
// Point the custom path to a path inside a read-only parent.
final readOnlyParent = Directory(p.join(tmpRoot.path, 'readonly'))..createSync(recursive: true);
try {
// Make parent unwritable so writing inside fails. Skip if the OS
// ignores the chmod (e.g. when running as root).
await Process.run('chmod', ['000', readOnlyParent.path]);
final blocked = p.join(readOnlyParent.path, 'forbidden');
await settings.write(SettingsService.customDownloadPathType, 'file');
@@ -134,16 +145,10 @@ void main() {
await dss.initialize(settings);
final dir = await dss.getDownloadsDirectory();
// Either the chmod worked → we fall back to default,
// or it didn't → we used the custom path. Both are valid; the
// important contract is that the call doesn't throw.
// The host may honor or ignore mode bits; either resolved root is
// valid for this smoke test as long as it exists.
expect(dir.existsSync(), isTrue);
if (dir.path == blocked) {
// chmod was a no-op (root or a filesystem that ignores it). Skip the
// strict assertion — the fallback branch only runs when writes fail.
return;
}
expect(dir.path, p.join(p.join(tmpRoot.path, 'support'), 'downloads'));
expect(dir.path, anyOf(blocked, p.join(tmpRoot.path, 'support', 'downloads')));
} finally {
await Process.run('chmod', ['755', readOnlyParent.path]);
}
@@ -12,10 +12,17 @@ import 'package:plezy/services/external_player_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/offline_watch_sync_service.dart';
import 'package:plezy/utils/active_client_scope.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
import '../test_helpers/media_items.dart';
class _RecordingClient implements MediaServerClient {
_RecordingClient({this.backend = MediaBackend.plex});
class _RecordingClient implements MediaServerClient, ScopedMediaServerClient {
_RecordingClient({this.backend = MediaBackend.plex, String? scopedServerId})
: scopedServerId =
scopedServerId ??
(backend == MediaBackend.plex
? buildPlexProfileScopeId(serverId: ServerId('srv'), profileId: 'profile-a')
: 'srv/user-a');
bool failStart = false;
bool failStop = false;
@@ -28,6 +35,8 @@ class _RecordingClient implements MediaServerClient {
@override
final MediaBackend backend;
@override
final String scopedServerId;
@override
double get watchedThreshold => 0.9;
@@ -138,6 +147,28 @@ void main() {
expect(action.shouldMarkWatched, isFalse);
});
test('Android external progress emits the exact client cache scope', () async {
final scope = buildPlexProfileScopeId(serverId: ServerId('srv'), profileId: 'profile-a');
final client = _RecordingClient(scopedServerId: scope);
final events = <WatchStateEvent>[];
final subscription = WatchStateNotifier()
.forItem('item-1')
.where((event) => event.changeType == WatchStateChangeType.progressUpdate)
.listen(events.add);
addTearDown(subscription.cancel);
await ExternalPlayerService.reportAndroidExternalProgressForTesting(
positionMs: 5000,
durationMs: 100000,
metadata: _item(durationMs: 100000),
client: client,
);
await Future<void>.delayed(Duration.zero);
expect(events, hasLength(1));
expect(events.single.cacheServerId, scope);
});
test('Android external progress ignores missing position without explicit completion', () async {
final client = _RecordingClient();
@@ -218,6 +218,31 @@ void main() {
expect(pinned['$machineId:item-1']!.serverName, 'Shared JF');
});
test('compound scope filtering selects only that user from legacy bare-scope rows', () async {
const machineId = 'jf-machine';
await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF');
await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF');
await putItemRow(
serverId: ServerId(machineId),
userId: 'user-a',
itemId: 'item-a',
data: jellyfinItem(id: 'item-a', name: 'For A'),
pinned: true,
);
await putItemRow(
serverId: ServerId(machineId),
userId: 'user-b',
itemId: 'item-b',
data: jellyfinItem(id: 'item-b', name: 'For B'),
pinned: true,
);
final pinned = await cache.getAllPinnedMetadata(cacheServerIds: {ServerId('$machineId/user-b')});
expect(pinned.keys, ['$machineId:item-b']);
expect(pinned.values.single.title, 'For B');
});
test('skips pinned rows whose serverId has no matching connection', () async {
await putItemRow(serverId: ServerId('orphan-machine'), userId: 'u', itemId: 'lost', pinned: true);
expect(await cache.getAllPinnedMetadata(), isEmpty);
+423 -12
View File
@@ -8,10 +8,16 @@ import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/utils/app_logger.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/utils/log_redaction_manager.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/media_items.dart';
JellyfinConnection _conn({String baseUrl = 'https://jf.example.com', List<String>? baseUrls}) => testJellyfinConnection(
baseUrl: baseUrl,
@@ -24,6 +30,26 @@ JellyfinConnection _conn({String baseUrl = 'https://jf.example.com', List<String
JellyfinClient _withMock(MockClient mock) => testJellyfinClient(connection: _conn(), httpClient: mock);
class _AbortAwareClient extends http.BaseClient {
final requestStarted = Completer<void>();
final _response = Completer<http.StreamedResponse>();
Uri? _requestUri;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
_requestUri = request.url;
if (!requestStarted.isCompleted) requestStarted.complete();
return _response.future;
}
@override
void close() {
if (!_response.isCompleted) {
_response.completeError(http.RequestAbortedException(_requestUri));
}
}
}
/// Failure-path coverage for the Jellyfin HTTP layer.
///
/// The original test suite covered the 200-OK happy paths and a single 404
@@ -40,9 +66,15 @@ void main() {
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
JellyfinApiCache.initialize(db);
MemoryLogOutput.clearLogs();
LogRedactionManager.clearTrackedValues();
setLoggerLevel(false);
});
tearDown(() async {
await db.close();
MemoryLogOutput.clearLogs();
LogRedactionManager.clearTrackedValues();
setLoggerLevel(true);
});
group('JellyfinClient.fetchItem failure modes', () {
@@ -140,27 +172,143 @@ void main() {
});
group('JellyfinClient endpoint failover', () {
test('switches to the fallback URL after a transient GET failure', () async {
final requests = <Uri>[];
http.Response publicInfo([String id = 'srv-1']) => http.Response(
jsonEncode({'Id': id, 'ServerName': 'Home', 'Version': '10.9.0'}),
200,
headers: {'content-type': 'application/json'},
);
test('validates publicly before authenticated fallback and persists one promotion', () async {
const primary = 'https://primary-client-canary.invalid/primary-private-base';
const fallback = 'https://fallback-client-canary.invalid/fallback-private-base';
final events = <String>[];
final applicationRequests = <http.Request>[];
final probeRequests = <http.Request>[];
final persisted = <JellyfinConnection>[];
final client = JellyfinClient.forTesting(
connection: _conn(
baseUrl: 'https://primary.example.com',
baseUrls: const ['https://primary.example.com', 'https://fallback.example.com'],
),
httpClient: MockClient((req) async {
requests.add(req.url);
if (req.url.host == 'primary.example.com') {
connection: _conn(baseUrl: primary, baseUrls: const [primary, fallback]),
httpClient: MockClient((request) async {
applicationRequests.add(request);
events.add('application:${request.url.host}');
expect(request.headers['X-Emby-Token'], 'tok-abc');
if (request.url.host == 'primary-client-canary.invalid') {
throw TimeoutException('primary down');
}
return http.Response(jsonEncode({'Id': 'srv-1'}), 200, headers: {'content-type': 'application/json'});
}),
endpointProbeHttpClientFactory: () => MockClient((request) async {
probeRequests.add(request);
events.add('probe:${request.url.host}');
return publicInfo();
}),
);
client.onConnectionUpdated = persisted.add;
addTearDown(client.close);
expect(await client.getMachineIdentifier(), 'srv-1');
expect(requests.map((uri) => uri.host), ['primary.example.com', 'fallback.example.com']);
expect(client.connection.baseUrl, 'https://fallback.example.com');
expect(client.connection.baseUrls, ['https://fallback.example.com', 'https://primary.example.com']);
expect(events, [
'application:primary-client-canary.invalid',
'probe:fallback-client-canary.invalid',
'application:fallback-client-canary.invalid',
]);
expect(applicationRequests, hasLength(2));
expect(probeRequests, hasLength(1));
expect(probeRequests.single.headers.keys.map((name) => name.toLowerCase()), isNot(contains('authorization')));
expect(probeRequests.single.headers.keys.map((name) => name.toLowerCase()), isNot(contains('x-emby-token')));
expect(client.connection.baseUrl, fallback);
expect(client.connection.baseUrls, [fallback, primary]);
expect(persisted, hasLength(1));
expect(persisted.single.baseUrl, fallback);
final storedFields = MemoryLogOutput.getLogs().expand<String>(
(entry) => [entry.message, if (entry.error != null) entry.error.toString()],
);
for (final field in storedFields) {
expect(field, isNot(contains('primary-client-canary.invalid')));
expect(field, isNot(contains('primary-private-base')));
expect(field, isNot(contains('fallback-client-canary.invalid')));
expect(field, isNot(contains('fallback-private-base')));
}
});
test('wrong-machine fallback is skipped before one authenticated retry to a valid fallback', () async {
final events = <String>[];
final applicationRequests = <http.Request>[];
final persisted = <JellyfinConnection>[];
var exhausted = 0;
final client = JellyfinClient.forTesting(
connection: _conn(
baseUrl: 'https://primary.example.com',
baseUrls: const [
'https://primary.example.com',
'https://wrong-machine.example.com',
'https://valid.example.com',
],
),
httpClient: MockClient((request) async {
applicationRequests.add(request);
events.add('application:${request.url.host}');
if (request.url.host == 'primary.example.com') {
throw TimeoutException('primary down');
}
expect(request.url.host, 'valid.example.com');
return http.Response(jsonEncode({'Id': 'srv-1'}), 200, headers: {'content-type': 'application/json'});
}),
endpointProbeHttpClientFactory: () => MockClient((request) async {
events.add('probe:${request.url.host}');
expect(request.headers.keys.map((name) => name.toLowerCase()), isNot(contains('x-emby-token')));
return publicInfo(request.url.host == 'wrong-machine.example.com' ? 'srv-other' : 'srv-1');
}),
onAllEndpointsExhausted: () => exhausted++,
);
client.onConnectionUpdated = persisted.add;
addTearDown(client.close);
expect(await client.getMachineIdentifier(), 'srv-1');
expect(events, [
'application:primary.example.com',
'probe:wrong-machine.example.com',
'probe:valid.example.com',
'application:valid.example.com',
]);
expect(applicationRequests, hasLength(2));
expect(exhausted, 0);
expect(persisted, hasLength(1));
expect(persisted.single.baseUrl, 'https://valid.example.com');
expect(client.connection.baseUrl, 'https://valid.example.com');
});
test('unreachable fallback receives no authenticated application request', () async {
final events = <String>[];
final persisted = <JellyfinConnection>[];
var exhausted = 0;
final client = JellyfinClient.forTesting(
connection: _conn(
baseUrl: 'https://primary.example.com',
baseUrls: const ['https://primary.example.com', 'https://unreachable.example.com'],
),
httpClient: MockClient((request) async {
events.add('application:${request.url.host}');
throw TimeoutException('primary down');
}),
endpointProbeHttpClientFactory: () => MockClient((request) async {
events.add('probe:${request.url.host}');
expect(request.headers.keys.map((name) => name.toLowerCase()), isNot(contains('x-emby-token')));
throw TimeoutException('probe unavailable');
}),
onAllEndpointsExhausted: () => exhausted++,
);
client.onConnectionUpdated = persisted.add;
addTearDown(client.close);
expect(await client.getMachineIdentifier(), 'srv-1');
expect(events, ['application:primary.example.com', 'probe:unreachable.example.com']);
expect(exhausted, 1);
expect(persisted, isEmpty);
expect(client.connection.baseUrl, 'https://primary.example.com');
});
test('hub surfaces retry transient failures without hopping endpoints', () async {
@@ -194,6 +342,7 @@ void main() {
baseUrls: const ['https://primary.example.com', 'https://fallback.example.com'],
),
httpClient: MockClient((req) async => throw TimeoutException('endpoint down')),
endpointProbeHttpClientFactory: () => MockClient((_) async => publicInfo()),
onAllEndpointsExhausted: () => exhausted++,
);
addTearDown(client.close);
@@ -217,6 +366,7 @@ void main() {
}
return http.Response(jsonEncode({'Id': 'srv-1'}), 200, headers: {'content-type': 'application/json'});
}),
endpointProbeHttpClientFactory: () => MockClient((_) async => publicInfo()),
);
addTearDown(client.close);
@@ -290,4 +440,265 @@ void main() {
expect(await failingClient.fetchMoreHubItems('home.nextup'), isEmpty);
});
});
group('JellyfinClient.getPlaybackInfo failure contract', () {
test('preserves 401, 403, and 500 status failures', () async {
for (final status in [401, 403, 500]) {
final client = _withMock(
MockClient(
(_) async =>
http.Response(jsonEncode({'error': 'redacted'}), status, headers: {'content-type': 'application/json'}),
),
);
addTearDown(client.close);
await expectLater(
client.getPlaybackInfo('item-1'),
throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', status)),
);
}
});
test('preserves timeout classifications', () async {
final timeoutClient = _withMock(MockClient((_) async => throw TimeoutException('timed out')));
addTearDown(timeoutClient.close);
await expectLater(
timeoutClient.getPlaybackInfo('item-1'),
throwsA(
isA<MediaServerHttpException>().having(
(error) => error.type,
'type',
MediaServerHttpErrorType.connectionTimeout,
),
),
);
final receiveTimeoutClient = _withMock(
MockClient(
(_) async =>
throw MediaServerHttpException(type: MediaServerHttpErrorType.receiveTimeout, message: 'timed out'),
),
);
addTearDown(receiveTimeoutClient.close);
await expectLater(
receiveTimeoutClient.getPlaybackInfo('item-1'),
throwsA(
isA<MediaServerHttpException>().having(
(error) => error.type,
'type',
MediaServerHttpErrorType.receiveTimeout,
),
),
);
});
test('client close preserves real in-flight cancellation', () async {
final transport = _AbortAwareClient();
final client = testJellyfinClient(connection: _conn(), httpClient: transport);
final playbackInfo = client.getPlaybackInfo('item-1');
await transport.requestStarted.future;
client.close();
await expectLater(
playbackInfo,
throwsA(isA<MediaServerHttpException>().having((error) => error.isCancellation, 'isCancellation', isTrue)),
);
});
test('rejects invalid JSON and malformed successful shapes without retaining payload', () async {
final responses = <http.Response>[
http.Response('{', 200, headers: {'content-type': 'application/json'}),
http.Response(jsonEncode([]), 200, headers: {'content-type': 'application/json'}),
http.Response(jsonEncode({'unrelated': 'payload-canary'}), 200, headers: {'content-type': 'application/json'}),
http.Response(
jsonEncode({'MediaSources': 'payload-canary'}),
200,
headers: {'content-type': 'application/json'},
),
];
for (final response in responses) {
final client = _withMock(MockClient((_) async => response));
addTearDown(client.close);
await expectLater(client.getPlaybackInfo('item-1'), throwsA(isA<MediaServerHttpException>()));
}
for (final body in [
<String, dynamic>{'unrelated': 'payload-canary'},
<String, dynamic>{'MediaSources': 'payload-canary'},
]) {
final client = _withMock(
MockClient((_) async => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'})),
);
addTearDown(client.close);
try {
await client.getPlaybackInfo('item-1');
fail('Malformed PlaybackInfo must throw');
} on MediaServerHttpException catch (error) {
expect(error.statusCode, 200);
expect(error.responseData, isNull);
expect(error.requestUri, isNull);
expect(error.toString(), isNot(contains('payload-canary')));
}
}
});
test('accepts a successful empty source list', () async {
final client = _withMock(
MockClient(
(_) async =>
http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}),
),
);
addTearDown(client.close);
expect(await client.getPlaybackInfo('item-1'), {'MediaSources': []});
});
});
group('Jellyfin mutation result families', () {
final item = testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1');
test('void mutation completes on success and preserves status/transport failures', () async {
final success = _withMock(MockClient((_) async => http.Response('', 204)));
addTearDown(success.close);
await success.markWatched(item);
for (final status in [400, 500]) {
final failing = _withMock(MockClient((_) async => http.Response('{}', status)));
addTearDown(failing.close);
await expectLater(
failing.markWatched(item),
throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', status)),
);
}
final timeout = _withMock(MockClient((_) async => throw TimeoutException('timed out')));
addTearDown(timeout.close);
await expectLater(
timeout.markWatched(item),
throwsA(
isA<MediaServerHttpException>().having(
(error) => error.type,
'type',
MediaServerHttpErrorType.connectionTimeout,
),
),
);
});
test('nullable playlist creation returns entity/null and throws request failures', () async {
final valid = _withMock(
MockClient((request) async {
if (request.url.path == '/Playlists') {
return http.Response(jsonEncode({'Id': 'playlist-1'}), 200, headers: {'content-type': 'application/json'});
}
if (request.url.path == '/Users/user-1/Items/playlist-1') {
return http.Response(
jsonEncode({'Id': 'playlist-1', 'Name': 'Playlist', 'Type': 'Playlist', 'MediaType': 'Video'}),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('{}', 404);
}),
);
addTearDown(valid.close);
expect((await valid.createPlaylist(title: 'Playlist', items: const []))?.id, 'playlist-1');
final unusable = _withMock(
MockClient((_) async => http.Response(jsonEncode({}), 200, headers: {'content-type': 'application/json'})),
);
addTearDown(unusable.close);
expect(await unusable.createPlaylist(title: 'Playlist', items: const []), isNull);
for (final status in [400, 500]) {
final failing = _withMock(MockClient((_) async => http.Response('{}', status)));
addTearDown(failing.close);
await expectLater(
failing.createPlaylist(title: 'Playlist', items: const []),
throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', status)),
);
}
final timeout = _withMock(MockClient((_) async => throw TimeoutException('timed out')));
addTearDown(timeout.close);
await expectLater(
timeout.createPlaylist(title: 'Playlist', items: const []),
throwsA(isA<MediaServerHttpException>()),
);
});
test('nullable collection creation returns id/null and throws request failures', () async {
final valid = _withMock(
MockClient(
(_) async =>
http.Response(jsonEncode({'Id': 'collection-1'}), 200, headers: {'content-type': 'application/json'}),
),
);
addTearDown(valid.close);
expect(
await valid.createCollection(libraryId: 'library-1', title: 'Collection', items: const []),
'collection-1',
);
final unusable = _withMock(
MockClient((_) async => http.Response(jsonEncode({}), 200, headers: {'content-type': 'application/json'})),
);
addTearDown(unusable.close);
expect(await unusable.createCollection(libraryId: 'library-1', title: 'Collection', items: const []), isNull);
for (final status in [400, 500]) {
final failing = _withMock(MockClient((_) async => http.Response('{}', status)));
addTearDown(failing.close);
await expectLater(
failing.createCollection(libraryId: 'library-1', title: 'Collection', items: const []),
throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', status)),
);
}
final timeout = _withMock(MockClient((_) async => throw TimeoutException('timed out')));
addTearDown(timeout.close);
await expectLater(
timeout.createCollection(libraryId: 'library-1', title: 'Collection', items: const []),
throwsA(isA<MediaServerHttpException>()),
);
});
test('playlist move returns false only for local preconditions and throws request failures', () async {
var requests = 0;
final localOnly = _withMock(
MockClient((_) async {
requests++;
return http.Response('', 204);
}),
);
addTearDown(localOnly.close);
const wrongBackend = PlexMediaItem(id: 'item-1', kind: MediaKind.movie);
const missingEntry = JellyfinMediaItem(id: 'item-1', kind: MediaKind.movie);
expect(
await localOnly.movePlaylistItem(playlistId: 'playlist', item: wrongBackend, newIndex: 0, afterItem: null),
isFalse,
);
expect(
await localOnly.movePlaylistItem(playlistId: 'playlist', item: missingEntry, newIndex: 0, afterItem: null),
isFalse,
);
expect(requests, 0);
const validEntry = JellyfinMediaItem(id: 'item-1', kind: MediaKind.movie, playlistItemId: 'entry-1');
final success = _withMock(MockClient((_) async => http.Response('', 204)));
addTearDown(success.close);
expect(
await success.movePlaylistItem(playlistId: 'playlist', item: validEntry, newIndex: 0, afterItem: null),
isTrue,
);
final failing = _withMock(MockClient((_) async => http.Response('{}', 500)));
addTearDown(failing.close);
await expectLater(
failing.movePlaylistItem(playlistId: 'playlist', item: validEntry, newIndex: 0, afterItem: null),
throwsA(isA<MediaServerHttpException>()),
);
});
});
}
File diff suppressed because it is too large Load Diff
@@ -157,9 +157,11 @@ void main() {
);
});
test('retains explicit user-entered failover URLs when using input candidates', () async {
test('persists only explicit URLs that proved the selected machine identity', () async {
final probeRequests = <http.Request>[];
final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => MockClient((req) async {
probeRequests.add(req);
if (req.url.host == 'offline.example.com') {
throw TimeoutException('offline');
}
@@ -178,7 +180,14 @@ void main() {
);
expect(result.activeBaseUrl, 'https://jf.example.com');
expect(result.baseUrls, ['https://jf.example.com', 'https://offline.example.com']);
expect(result.baseUrls, ['https://jf.example.com']);
expect(probeRequests, isNotEmpty);
for (final request in probeRequests) {
final headerNames = request.headers.keys.map((name) => name.toLowerCase());
expect(headerNames, isNot(contains('authorization')));
expect(headerNames, isNot(contains('x-emby-token')));
expect(request.url.queryParameters.keys.map((name) => name.toLowerCase()), isNot(contains('api_key')));
}
});
test('races URLs and selects the lowest-latency reachable endpoint', () async {
@@ -200,7 +209,7 @@ void main() {
expect(result.serverInfo.machineId, 'srv-1');
});
test('keeps unreachable URLs but validates every reachable URL is the same server', () async {
test('excludes unreachable URLs from the trusted failover list', () async {
final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => MockClient((req) async {
if (req.url.host == 'offline.example.com') {
@@ -213,7 +222,7 @@ void main() {
final result = await discovery.raceEndpoints(['https://offline.example.com', 'https://jf.example.com']);
expect(result.activeBaseUrl, 'https://jf.example.com');
expect(result.baseUrls, ['https://jf.example.com', 'https://offline.example.com']);
expect(result.baseUrls, ['https://jf.example.com']);
});
test('rejects reachable URLs that point to different Jellyfin servers', () async {
@@ -239,6 +248,90 @@ void main() {
throwsA(isA<MediaServerUrlException>()),
);
});
test('expected machine ID keeps only reachable matching candidates', () async {
final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => MockClient((request) async {
if (request.url.host == 'offline.example.com') {
throw TimeoutException('offline');
}
return _info(id: 'srv-1');
}),
);
final result = await discovery.raceEndpoints([
'https://matching.example.com',
'https://offline.example.com',
], expectedMachineId: 'srv-1');
expect(result.activeBaseUrl, 'https://matching.example.com');
expect(result.baseUrls, ['https://matching.example.com']);
});
test('reconciles stored endpoints without pruning candidates that returned no identity', () async {
final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => MockClient((request) async {
if (request.url.host == 'offline.example.com') {
throw TimeoutException('offline');
}
return _info(id: request.url.host == 'wrong.example.com' ? 'srv-2' : 'srv-1');
}),
);
const storedBaseUrls = ['https://active.example.com', 'https://offline.example.com', 'https://wrong.example.com'];
final result = await discovery.raceEndpoints(
storedBaseUrls,
expectedMachineId: 'srv-1',
baseUrlsToValidate: const [],
);
expect(result.baseUrls, ['https://active.example.com']);
expect(result.reconcilePreviouslyStoredBaseUrls(storedBaseUrls), [
'https://active.example.com',
'https://offline.example.com',
]);
});
test('waits for a late phase-one identity before filtering persisted fallbacks', () async {
final allowLateIdentity = Completer<void>();
final lateProbeStarted = Completer<void>();
final phaseTwoFallbackFinished = Completer<void>();
var fallbackRequests = 0;
final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => MockClient((request) async {
if (request.url.host != 'fallback.example.com') {
return _info(id: 'srv-1');
}
fallbackRequests++;
if (fallbackRequests == 1) {
lateProbeStarted.complete();
await allowLateIdentity.future;
return _info(id: 'srv-1');
}
phaseTwoFallbackFinished.complete();
throw TimeoutException('phase-two fallback probe failed');
}),
);
var raceCompleted = false;
final raceFuture = discovery
.raceEndpoints(['https://active.example.com', 'https://fallback.example.com'], expectedMachineId: 'srv-1')
.then((result) {
raceCompleted = true;
return result;
});
await lateProbeStarted.future;
await phaseTwoFallbackFinished.future;
await Future<void>.delayed(Duration.zero);
expect(raceCompleted, isFalse);
allowLateIdentity.complete();
final result = await raceFuture;
expect(result.activeBaseUrl, 'https://active.example.com');
expect(result.baseUrls, ['https://active.example.com', 'https://fallback.example.com']);
});
test('promotes a same-host HTTPS redirect before persisting the endpoint', () async {
final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => _RedirectedInfoClient((requestedUrl) => requestedUrl.replace(scheme: 'https')),
@@ -251,6 +344,7 @@ void main() {
expect(result.activeBaseUrl, 'https://jf.example.com');
expect(result.baseUrls, ['https://jf.example.com']);
expect(result.reconcilePreviouslyStoredBaseUrls(['http://jf.example.com']), ['https://jf.example.com']);
});
test('preserves a Jellyfin base path when promoting a redirect', () async {
@@ -1,9 +1,11 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/models/livetv_channel.dart';
import 'package:plezy/services/favorite_channels_repository.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -20,11 +22,13 @@ JellyfinConnection _conn({required String userId}) => testJellyfinConnection(
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
JellyfinClient _client(JellyfinConnection conn) => testJellyfinClient(
connection: conn,
// Favorites read path is local-only; any HTTP call is a test failure.
handler: (_) async => throw StateError('no HTTP expected'),
);
JellyfinClient _client(JellyfinConnection conn, {FavoriteChannelsRepository? favoritesRepository}) =>
JellyfinClient.forTesting(
connection: conn,
favoritesRepository: favoritesRepository,
// Favorites read path is local-only; any HTTP call is a test failure.
httpClient: MockClient((_) async => throw StateError('no HTTP expected')),
);
String _favKey(JellyfinConnection conn) => 'jellyfin_fav_channels:${conn.id}';
String _legacyFavKey(JellyfinConnection conn) => 'jellyfin_fav_channels:${conn.serverMachineId}';
@@ -67,5 +71,27 @@ void main() {
expect(prefs.getString(_legacyFavKey(connA)), isNull);
expect(prefs.getString(_favKey(connA)), isNotNull);
});
test('repository read failures propagate through the favorite Future', () async {
final failure = StateError('favorite repository unavailable');
final client = _client(
_conn(userId: 'user-a'),
favoritesRepository: _ThrowingFavoriteChannelsRepository(failure),
);
addTearDown(client.close);
await expectLater(client.liveTv.fetchFavoriteChannels(), throwsA(same(failure)));
});
});
}
class _ThrowingFavoriteChannelsRepository implements FavoriteChannelsRepository {
const _ThrowingFavoriteChannelsRepository(this.failure);
final Object failure;
@override
Future<List<FavoriteChannel>> read({required String key, required String legacyKey}) => Future.error(failure);
@override
Future<void> write(String key, List<FavoriteChannel> channels) async {}
}
@@ -0,0 +1,214 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/models/livetv_channel.dart';
import 'package:plezy/services/favorite_channels_repository.dart';
import 'package:plezy/services/jellyfin_client.dart';
import '../test_helpers/backend_client_fixtures.dart';
FavoriteChannel _favorite(String id, {String? title}) =>
FavoriteChannel(id: id, title: title ?? id, source: 'server://test-server/jellyfin');
JellyfinClient _client(
_MemoryFavoriteChannelsRepository repository,
Future<http.Response> Function(http.Request request) handler,
) => JellyfinClient.forTesting(
connection: testJellyfinConnection(machineId: 'test-server', userId: 'test-user'),
httpClient: MockClient(handler),
favoritesRepository: repository,
);
String _mutationId(http.Request request) => request.url.pathSegments.last;
void main() {
test('mixed outcomes persist confirmed projection and retry only unconfirmed differences', () async {
final repository = _MemoryFavoriteChannelsRepository([
_favorite('keep', title: 'Old keep'),
_favorite('remove-ok'),
_favorite('remove-fail'),
]);
final attempts = <String>[];
var failingIds = {'add-fail', 'remove-fail'};
final client = _client(repository, (request) async {
final id = _mutationId(request);
attempts.add('${request.method}:$id');
return http.Response('', failingIds.contains(id) ? 400 : 204);
});
addTearDown(client.close);
final desired = [
_favorite('keep', title: 'Updated keep'),
_favorite('add-ok', title: 'Added'),
_favorite('add-fail'),
];
await expectLater(
client.liveTv.setFavoriteChannels(desired),
throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', 400)),
);
expect(attempts, ['POST:add-ok', 'POST:add-fail', 'DELETE:remove-ok', 'DELETE:remove-fail']);
expect(repository.writeCount, 1);
expect(repository.current.map((channel) => channel.id), ['keep', 'add-ok', 'remove-fail']);
expect(repository.current.first.title, 'Updated keep');
attempts.clear();
failingIds = {};
await client.liveTv.setFavoriteChannels(desired);
expect(attempts, ['POST:add-fail', 'DELETE:remove-fail']);
expect(repository.writeCount, 2);
expect(repository.current.map((channel) => channel.id), ['keep', 'add-ok', 'add-fail']);
});
test('a rejected single addition retains the empty baseline and throws', () async {
final repository = _MemoryFavoriteChannelsRepository(const []);
var attempts = 0;
final client = _client(repository, (request) async {
attempts++;
return http.Response('', 409);
});
addTearDown(client.close);
await expectLater(client.liveTv.setFavoriteChannels([_favorite('add')]), throwsA(isA<MediaServerHttpException>()));
expect(attempts, 1);
expect(repository.current, isEmpty);
});
test('a rejected single removal retains the prior favorite and throws', () async {
final repository = _MemoryFavoriteChannelsRepository([_favorite('remove')]);
var attempts = 0;
final client = _client(repository, (request) async {
attempts++;
return http.Response('', 409);
});
addTearDown(client.close);
await expectLater(client.liveTv.setFavoriteChannels(const []), throwsA(isA<MediaServerHttpException>()));
expect(attempts, 1);
expect(repository.current.map((channel) => channel.id), ['remove']);
});
test('successful writes preserve requested order and metadata without redundant reorder requests', () async {
final repository = _MemoryFavoriteChannelsRepository([_favorite('first'), _favorite('second')]);
final attempts = <String>[];
final client = _client(repository, (request) async {
attempts.add('${request.method}:${_mutationId(request)}');
return http.Response('', 204);
});
addTearDown(client.close);
await client.liveTv.setFavoriteChannels([
_favorite('second', title: 'Second updated'),
_favorite('third', title: 'Third added'),
_favorite('first', title: 'First updated'),
]);
expect(attempts, ['POST:third']);
expect(repository.current.map((channel) => channel.id), ['second', 'third', 'first']);
expect(repository.current.map((channel) => channel.title), ['Second updated', 'Third added', 'First updated']);
attempts.clear();
await client.liveTv.setFavoriteChannels([
_favorite('first', title: 'First newest'),
_favorite('second', title: 'Second newest'),
_favorite('third', title: 'Third newest'),
]);
expect(attempts, isEmpty);
expect(repository.current.map((channel) => channel.id), ['first', 'second', 'third']);
expect(repository.current.map((channel) => channel.title), ['First newest', 'Second newest', 'Third newest']);
});
test('baseline read failure aborts before HTTP mutation and persistence', () async {
final failure = StateError('baseline unavailable');
final repository = _MemoryFavoriteChannelsRepository(const [], readError: failure);
var attempts = 0;
final client = _client(repository, (request) async {
attempts++;
return http.Response('', 204);
});
addTearDown(client.close);
await expectLater(client.liveTv.setFavoriteChannels([_favorite('add')]), throwsA(same(failure)));
expect(attempts, 0);
expect(repository.writeAttempts, 0);
});
test('durable write failure takes precedence over completed server mutations', () async {
final failure = StateError('durable write unavailable');
final repository = _MemoryFavoriteChannelsRepository(const [], writeError: failure);
var attempts = 0;
final client = _client(repository, (request) async {
attempts++;
return http.Response('', 204);
});
addTearDown(client.close);
await expectLater(client.liveTv.setFavoriteChannels([_favorite('add')]), throwsA(same(failure)));
expect(attempts, 1);
expect(repository.writeAttempts, 1);
expect(repository.current, isEmpty);
});
test('ambiguous timeout retains prior state, stays typed, and retries the absolute intent', () async {
final repository = _MemoryFavoriteChannelsRepository(const []);
var attempts = 0;
var shouldTimeout = true;
final client = _client(repository, (request) async {
attempts++;
if (shouldTimeout) throw TimeoutException('request timed out');
return http.Response('', 204);
});
addTearDown(client.close);
final desired = [_favorite('add')];
await expectLater(
client.liveTv.setFavoriteChannels(desired),
throwsA(
isA<MediaServerHttpException>().having(
(error) => error.type,
'type',
MediaServerHttpErrorType.connectionTimeout,
),
),
);
expect(repository.current, isEmpty);
shouldTimeout = false;
await client.liveTv.setFavoriteChannels(desired);
expect(attempts, 2);
expect(repository.current.map((channel) => channel.id), ['add']);
});
}
class _MemoryFavoriteChannelsRepository implements FavoriteChannelsRepository {
_MemoryFavoriteChannelsRepository(List<FavoriteChannel> initial, {this.readError, this.writeError})
: current = List.of(initial);
List<FavoriteChannel> current;
final Object? readError;
final Object? writeError;
int writeAttempts = 0;
int writeCount = 0;
@override
Future<List<FavoriteChannel>> read({required String key, required String legacyKey}) async {
if (readError case final error?) throw error;
return List.of(current);
}
@override
Future<void> write(String key, List<FavoriteChannel> channels) async {
writeAttempts++;
if (writeError case final error?) throw error;
current = List.of(channels);
writeCount++;
}
}
+24 -1
View File
@@ -374,14 +374,37 @@ void main() {
expect(info.trickplayByWidth![320]!.width, 320);
});
test('falls back to first nested entry when source id not present as key', () {
test('does not attach another source trickplay when selected source is absent', () {
final info = jellyfinMediaSourceToMediaSourceInfo(
{'Id': 'unknown', 'MediaStreams': []},
trickplay: {
'src-1': {'160': _info(width: 160, height: 90, tw: 4, th: 4, count: 16, interval: 10000)},
},
);
expect(info.mediaSourceId, 'unknown');
expect(info.trickplayByWidth, isNull);
});
test('source-less media accepts exactly one nested trickplay candidate', () {
final info = jellyfinMediaSourceToMediaSourceInfo(
{'MediaStreams': []},
trickplay: {
'src-1': {'160': _info(width: 160, height: 90, tw: 4, th: 4, count: 16, interval: 10000)},
},
);
expect(info.trickplayByWidth?.keys.single, 160);
expect(info.trickplayByWidth?[160]?.interval, 10000);
});
test('source-less media rejects ambiguous nested trickplay candidates', () {
final info = jellyfinMediaSourceToMediaSourceInfo(
{'MediaStreams': []},
trickplay: {
'src-1': {'160': _info(width: 160, height: 90, tw: 4, th: 4, count: 16, interval: 10000)},
'src-2': {'320': _info(width: 320, height: 180, tw: 4, th: 4, count: 16, interval: 10000)},
},
);
expect(info.trickplayByWidth, isNull);
});
test('returns null trickplayByWidth when manifest missing', () {
@@ -0,0 +1,101 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/utils/app_logger.dart';
import 'package:plezy/utils/log_redaction_manager.dart';
import '../test_helpers/backend_client_fixtures.dart';
void main() {
setUp(() {
MemoryLogOutput.clearLogs();
LogRedactionManager.clearTrackedValues();
});
tearDown(() {
MemoryLogOutput.clearLogs();
LogRedactionManager.clearTrackedValues();
});
test('missing playlist entry diagnostics contain no media title or ID', () async {
const titleCanary = 'PRIVATE-TITLE-CANARY';
const idCanary = 'PRIVATE-ID-CANARY';
final methods = <String>[];
final client = JellyfinClient.forTesting(
connection: testJellyfinConnection(),
httpClient: MockClient((request) async {
methods.add(request.method);
return http.Response(
jsonEncode({
'Items': [
{'Id': idCanary, 'Name': titleCanary, 'Type': 'Movie'},
],
'TotalRecordCount': 1,
}),
200,
headers: {'content-type': 'application/json'},
);
}),
);
addTearDown(client.close);
final item = (await client.fetchPlaylistPage('playlist')).items.single;
expect(item, isA<JellyfinMediaItem>());
expect(item.title, titleCanary);
expect(item.id, idCanary);
expect((item as JellyfinMediaItem).playlistItemId, isNull);
expect(await client.movePlaylistItem(playlistId: 'playlist', item: item, newIndex: 0, afterItem: null), isFalse);
expect(await client.removeFromPlaylist(playlistId: 'playlist', item: item), isFalse);
expect(methods, ['GET']);
final retained = MemoryLogOutput.getLogs().expand((entry) => [entry.message, ?entry.error?.toString()]).join('\n');
expect(retained, isNot(contains(titleCanary)));
expect(retained, isNot(contains(idCanary)));
expect(retained, contains('Jellyfin movePlaylistItem failed: missing playlist entry ID'));
expect(retained, contains('Jellyfin removeFromPlaylist failed: missing playlist entry ID'));
});
test('valid playlist entries still perform move and removal mutations', () async {
const entryId = 'playlist-entry-1';
final requests = <({String method, Uri url})>[];
final client = JellyfinClient.forTesting(
connection: testJellyfinConnection(),
httpClient: MockClient((request) async {
requests.add((method: request.method, url: request.url));
if (request.method == 'GET') {
return http.Response(
jsonEncode({
'Items': [
{'Id': 'media-1', 'Name': 'Mapped title', 'Type': 'Movie', 'PlaylistItemId': entryId},
],
'TotalRecordCount': 1,
}),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('{}', 200, headers: {'content-type': 'application/json'});
}),
);
addTearDown(client.close);
final item = (await client.fetchPlaylistPage('playlist')).items.single;
expect(item, isA<JellyfinMediaItem>());
expect((item as JellyfinMediaItem).playlistItemId, entryId);
expect(await client.movePlaylistItem(playlistId: 'playlist', item: item, newIndex: 3, afterItem: null), isTrue);
expect(await client.removeFromPlaylist(playlistId: 'playlist', item: item), isTrue);
expect(requests.map((request) => request.method), ['GET', 'POST', 'DELETE']);
expect(requests[1].url.path, '/Playlists/playlist/Items/$entryId/Move/3');
expect(requests[2].url.path, '/Playlists/playlist/Items');
expect(requests[2].url.queryParameters['entryIds'], entryId);
final retained = MemoryLogOutput.getLogs().expand((entry) => [entry.message, ?entry.error?.toString()]).join('\n');
expect(retained, isNot(contains('missing playlist entry ID')));
});
}
@@ -1,4 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/library_query.dart';
@@ -12,6 +15,8 @@ import 'package:plezy/services/jellyfin_sequential_launcher.dart';
import 'package:plezy/services/media_list_playback_launcher.dart';
import 'package:plezy/services/playlist_items_loader.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/widgets/dialog_action_button.dart';
import 'package:plezy/i18n/strings.g.dart';
import '../test_helpers/paged_fakes.dart';
import '../test_helpers/media_items.dart';
@@ -26,6 +31,14 @@ class _RecordingJellyfinClient implements JellyfinClient {
final List<MediaItem> playableFolderDescendantsResponse;
final List<MediaItem> seriesEpisodesResponse;
final List<MediaItem> playlistItemsResponse;
final Completer<void>? playableDescendantsGate;
final Completer<void>? playableFolderDescendantsGate;
final Completer<void>? seriesEpisodesGate;
final Completer<void>? playlistPageGate;
final List<AbortController?> playableDescendantAborts = [];
final List<AbortController?> playableFolderAborts = [];
final List<AbortController?> seriesEpisodeAborts = [];
final List<AbortController?> playlistPageAborts = [];
final List<String> fetchPlayableDescendantsCalls = [];
final List<String> fetchPlayableFolderDescendantsCalls = [];
final List<String> fetchSeriesEpisodesCalls = [];
@@ -36,23 +49,33 @@ class _RecordingJellyfinClient implements JellyfinClient {
this.playableFolderDescendantsResponse = const [],
this.seriesEpisodesResponse = const [],
this.playlistItemsResponse = const [],
this.playableDescendantsGate,
this.playableFolderDescendantsGate,
this.seriesEpisodesGate,
this.playlistPageGate,
});
@override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
Future<List<MediaItem>> fetchPlayableDescendants(String parentId, {AbortController? abort}) async {
fetchPlayableDescendantsCalls.add(parentId);
playableDescendantAborts.add(abort);
await _waitForGate(playableDescendantsGate, abort);
return playableDescendantsResponse;
}
@override
Future<List<MediaItem>> fetchPlayableFolderDescendants(String parentId) async {
Future<List<MediaItem>> fetchPlayableFolderDescendants(String parentId, {AbortController? abort}) async {
fetchPlayableFolderDescendantsCalls.add(parentId);
playableFolderAborts.add(abort);
await _waitForGate(playableFolderDescendantsGate, abort);
return playableFolderDescendantsResponse;
}
@override
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId) async {
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId, {AbortController? abort}) async {
fetchSeriesEpisodesCalls.add(seriesId);
seriesEpisodeAborts.add(abort);
await _waitForGate(seriesEpisodesGate, abort);
return seriesEpisodesResponse;
}
@@ -67,9 +90,21 @@ class _RecordingJellyfinClient implements JellyfinClient {
final offset = start ?? 0;
final limit = size ?? fakeMediaPageSize;
fetchPlaylistItemsCalls.add((id: id, offset: offset, limit: limit));
playlistPageAborts.add(abort);
await _waitForGate(playlistPageGate, abort);
return fakeLibraryPage(playlistItemsResponse, start: start, size: size);
}
Future<void> _waitForGate(Completer<void>? gate, AbortController? abort) async {
if (gate == null) return;
if (abort == null) {
await gate.future;
return;
}
await Future.any<void>([gate.future, abort.trigger]);
abort.throwIfAborted();
}
@override
MediaBackend get backend => MediaBackend.jellyfin;
@@ -171,7 +206,12 @@ void main() {
context: ctx,
clientForTesting: fakeClient,
playbackStateForTesting: playback,
navigateForTesting: (m) async => navigated.add(m),
navigateForTesting: (m) async {
expect(playback.isQueueActive, isTrue);
expect(playback.loadedItems, orderedEquals(fetched));
expect(playback.currentQueueItem, same(fetched.first));
navigated.add(m);
},
);
final collection = testMediaItem(
@@ -685,5 +725,233 @@ void main() {
expect(playback.isQueueActive, isFalse);
expect(didNavigate, isFalse);
});
testWidgets('dialog Cancel aborts playlist launch idempotently without queue or snackbar', (tester) async {
final ctx = await pumpContext(tester);
final gate = Completer<void>();
final fakeClient = _RecordingJellyfinClient(playlistItemsResponse: [_ep('a'), _ep('b')], playlistPageGate: gate);
final playback = PlaybackStateProvider();
var didNavigate = false;
final launcher = JellyfinSequentialLauncher(
context: ctx,
clientForTesting: fakeClient,
playbackStateForTesting: playback,
navigateForTesting: (_) async {
didNavigate = true;
},
);
const playlist = MediaPlaylist(
id: 'pl-cancel',
backend: MediaBackend.jellyfin,
title: 'Cancel me',
playlistType: 'video',
serverId: 'srv-jf',
);
final resultFuture = launcher.launchFromCollectionOrPlaylist(item: playlist, shuffle: false);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(fakeClient.fetchPlaylistItemsCalls, hasLength(1));
expect(fakeClient.playlistPageAborts.single, isNotNull);
expect(find.text(t.common.cancel), findsOneWidget);
final cancelButton = tester.widget<DialogActionButton>(find.byType(DialogActionButton));
cancelButton.onPressed!();
cancelButton.onPressed!();
await tester.pump();
expect(await resultFuture, isA<PlayQueueCancelled>());
expect(fakeClient.playlistPageAborts.single!.isAborted, isTrue);
expect(fakeClient.fetchPlaylistItemsCalls, hasLength(1));
expect(playback.isQueueActive, isFalse);
expect(didNavigate, isFalse);
expect(find.byType(SnackBar), findsNothing);
expect(find.byType(Scaffold), findsOneWidget);
});
testWidgets('disposing the initiating navigator aborts its active playlist launch', (tester) async {
late BuildContext initiatingContext;
late StateSetter replaceProfileSubtree;
var showProfileSubtree = true;
await tester.pumpWidget(
MaterialApp(
home: StatefulBuilder(
builder: (context, setState) {
replaceProfileSubtree = setState;
if (!showProfileSubtree) {
return const Scaffold(body: Text('replacement profile', key: Key('replacement-profile')));
}
return Navigator(
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (_) => Scaffold(
body: Builder(
builder: (context) {
initiatingContext = context;
return const Text('initiating profile');
},
),
),
),
);
},
),
),
);
final gate = Completer<void>();
final fakeClient = _RecordingJellyfinClient(playlistItemsResponse: [_ep('a')], playlistPageGate: gate);
final playback = PlaybackStateProvider();
var didNavigate = false;
final launcher = JellyfinSequentialLauncher(
context: initiatingContext,
clientForTesting: fakeClient,
playbackStateForTesting: playback,
navigateForTesting: (_) async {
didNavigate = true;
},
);
const playlist = MediaPlaylist(
id: 'pl-teardown',
backend: MediaBackend.jellyfin,
title: 'Teardown',
playlistType: 'video',
serverId: 'srv-jf',
);
final resultFuture = launcher.launchFromCollectionOrPlaylist(item: playlist, shuffle: false);
await tester.pump();
expect(fakeClient.fetchPlaylistItemsCalls, hasLength(1));
replaceProfileSubtree(() => showProfileSubtree = false);
await tester.pump();
expect(await resultFuture, isA<PlayQueueCancelled>());
expect(fakeClient.playlistPageAborts.single!.isAborted, isTrue);
expect(fakeClient.fetchPlaylistItemsCalls, hasLength(1));
expect(playback.isQueueActive, isFalse);
expect(didNavigate, isFalse);
expect(find.byKey(const Key('replacement-profile')), findsOneWidget);
expect(find.byType(SnackBar), findsNothing);
});
testWidgets('collection cancellation suppresses mapping and publication', (tester) async {
final ctx = await pumpContext(tester);
final fakeClient = _RecordingJellyfinClient(
playableDescendantsResponse: [_ep('a')],
playableDescendantsGate: Completer<void>(),
);
final playback = PlaybackStateProvider();
final launcher = JellyfinSequentialLauncher(
context: ctx,
clientForTesting: fakeClient,
playbackStateForTesting: playback,
navigateForTesting: (_) async {},
);
final collection = testMediaItem(
id: 'col-cancel',
backend: MediaBackend.jellyfin,
kind: MediaKind.collection,
serverId: 'srv-jf',
);
final resultFuture = launcher.launchFromCollectionOrPlaylist(
item: collection,
shuffle: true,
showLoadingIndicator: false,
);
await tester.pump();
fakeClient.playableDescendantAborts.single!.abort();
expect(await resultFuture, isA<PlayQueueCancelled>());
expect(fakeClient.fetchPlayableDescendantsCalls, ['col-cancel']);
expect(playback.isQueueActive, isFalse);
});
testWidgets('folder cancellation suppresses filtering and publication', (tester) async {
final ctx = await pumpContext(tester);
final fakeClient = _RecordingJellyfinClient(
playableFolderDescendantsResponse: [_clip('a')],
playableFolderDescendantsGate: Completer<void>(),
);
final playback = PlaybackStateProvider();
final launcher = JellyfinSequentialLauncher(
context: ctx,
clientForTesting: fakeClient,
playbackStateForTesting: playback,
navigateForTesting: (_) async {},
);
final folder = testMediaItem(
id: 'folder-cancel',
backend: MediaBackend.jellyfin,
kind: MediaKind.unknown,
serverId: 'srv-jf',
);
final resultFuture = launcher.launchFromFolder(folder: folder, shuffle: true, showLoadingIndicator: false);
await tester.pump();
fakeClient.playableFolderAborts.single!.abort();
expect(await resultFuture, isA<PlayQueueCancelled>());
expect(fakeClient.fetchPlayableFolderDescendantsCalls, ['folder-cancel']);
expect(playback.isQueueActive, isFalse);
});
testWidgets('show cancellation suppresses shuffle and publication', (tester) async {
final ctx = await pumpContext(tester);
final fakeClient = _RecordingJellyfinClient(
seriesEpisodesResponse: [_ep('a')],
seriesEpisodesGate: Completer<void>(),
);
final playback = PlaybackStateProvider();
final launcher = JellyfinSequentialLauncher(
context: ctx,
clientForTesting: fakeClient,
playbackStateForTesting: playback,
navigateForTesting: (_) async {},
);
final show = testMediaItem(
id: 'show-cancel',
backend: MediaBackend.jellyfin,
kind: MediaKind.show,
serverId: 'srv-jf',
);
final resultFuture = launcher.launchShuffledShow(metadata: show, showLoadingIndicator: false);
await tester.pump();
fakeClient.seriesEpisodeAborts.single!.abort();
expect(await resultFuture, isA<PlayQueueCancelled>());
expect(fakeClient.fetchSeriesEpisodesCalls, ['show-cancel']);
expect(playback.isQueueActive, isFalse);
});
});
group('fetchAllPlaylistItems cancellation', () {
test('aborts after a page await without returning a partial list or requesting page two', () async {
final abort = AbortController();
final fakeClient = _RecordingJellyfinClient(
playlistItemsResponse: List.generate(playlistItemsPageSize + 1, (i) => _ep('p$i')),
playlistPageGate: Completer<void>(),
);
final resultFuture = fetchAllPlaylistItems(fakeClient, 'pl-abort', abort: abort);
expect(fakeClient.fetchPlaylistItemsCalls.map((call) => call.offset), [0]);
abort.abort();
await expectLater(
resultFuture,
throwsA(isA<MediaServerHttpException>().having((e) => e.isCancellation, 'isCancellation', isTrue)),
);
expect(fakeClient.fetchPlaylistItemsCalls.map((call) => call.offset), [0]);
});
test('null controller preserves two-page complete-list success', () async {
final items = List.generate(playlistItemsPageSize + 1, (i) => _ep('p$i'));
final fakeClient = _RecordingJellyfinClient(playlistItemsResponse: items);
final result = await fetchAllPlaylistItems(fakeClient, 'pl-success');
expect(result.map((item) => item.id), items.map((item) => item.id));
expect(fakeClient.fetchPlaylistItemsCalls.map((call) => call.offset), [0, playlistItemsPageSize]);
expect(fakeClient.playlistPageAborts, [null, null]);
});
});
}
@@ -109,6 +109,8 @@ void main() {
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
onScreenshot: () => feedbackCount++,
);
final repeatResult = service.handleVideoPlayerKeyEvent(
@@ -124,6 +126,8 @@ void main() {
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
onScreenshot: () => feedbackCount++,
);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
@@ -157,6 +161,8 @@ void main() {
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
onZoomIn: () => zoomInCount++,
);
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
@@ -185,6 +191,8 @@ void main() {
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
onZoomIn: () => zoomInCount++,
);
final repeatResult = service.handleVideoPlayerKeyEvent(
@@ -200,6 +208,8 @@ void main() {
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
onZoomIn: () => zoomInCount++,
);
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
@@ -229,6 +239,8 @@ void main() {
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
onZoomOut: () => zoomOutCount++,
);
final repeatResult = service.handleVideoPlayerKeyEvent(
@@ -244,6 +256,8 @@ void main() {
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
onZoomOut: () => zoomOutCount++,
);
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
@@ -273,6 +287,8 @@ void main() {
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
onZoomReset: () => resetCount++,
);
final repeatResult = service.handleVideoPlayerKeyEvent(
@@ -288,6 +304,8 @@ void main() {
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
onZoomReset: () => resetCount++,
);
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
@@ -317,6 +335,8 @@ void main() {
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
);
final commandQResult = service.handleVideoPlayerKeyEvent(
const KeyDownEvent(
@@ -331,6 +351,8 @@ void main() {
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
);
final commandCommaResult = service.handleVideoPlayerKeyEvent(
const KeyDownEvent(
@@ -345,6 +367,8 @@ void main() {
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
);
await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft);
@@ -354,32 +378,192 @@ void main() {
expect(commandCommaResult, KeyEventResult.ignored);
});
testWidgets('mute shortcut matches the button restoration behavior', (tester) async {
testWidgets('volume shortcuts delegate without mutating player or settings', (tester) async {
final service = await KeyboardShortcutsService.getInstance();
addTearDown(service.dispose);
final settings = SettingsService.instance;
await settings.write(SettingsService.volume, 37.0);
final player = _FakePlayer(volume: 37);
const muteKey = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.keyM,
logicalKey: LogicalKeyboardKey.keyM,
var upCalls = 0;
var downCalls = 0;
var muteCalls = 0;
const bindings = [
(action: 'volume_up', physical: PhysicalKeyboardKey.f10, logical: LogicalKeyboardKey.f10),
(action: 'volume_down', physical: PhysicalKeyboardKey.f11, logical: LogicalKeyboardKey.f11),
(action: 'mute_toggle', physical: PhysicalKeyboardKey.f12, logical: LogicalKeyboardKey.f12),
];
for (final binding in bindings) {
await service.setHotkey(binding.action, HotKey(key: binding.physical));
final result = service.handleVideoPlayerKeyEvent(
KeyDownEvent(physicalKey: binding.physical, logicalKey: binding.logical, timeStamp: Duration.zero),
player,
null,
null,
null,
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
onVolumeUp: () => upCalls++,
onVolumeDown: () => downCalls++,
onToggleMute: () => muteCalls++,
);
expect(result, KeyEventResult.handled);
}
expect(upCalls, 1);
expect(downCalls, 1);
expect(muteCalls, 1);
expect(player.volume, 37);
expect(player.volumeChanges, isEmpty);
expect(settings.read(SettingsService.volume), 37);
await service.setHotkey('volume_up', const HotKey(key: PhysicalKeyboardKey.f12));
final repeatResult = service.handleVideoPlayerKeyEvent(
const KeyRepeatEvent(
physicalKey: PhysicalKeyboardKey.f12,
logicalKey: LogicalKeyboardKey.f12,
timeStamp: Duration(milliseconds: 1),
),
player,
null,
null,
null,
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
onVolumeUp: () => upCalls++,
);
expect(repeatResult, KeyEventResult.handled);
expect(upCalls, 1);
});
test('denied playback shortcuts are consumed before any mutation', () async {
final service = await KeyboardShortcutsService.getInstance();
addTearDown(service.dispose);
final player = _FakePlayer();
final settings = SettingsService.instance;
final initialRate = settings.read(SettingsService.defaultPlaybackSpeed);
var callbacks = 0;
var seekCalls = 0;
const event = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.f12,
logicalKey: LogicalKeyboardKey.f12,
timeStamp: Duration.zero,
);
const controlledActions = <String>[
'play_pause',
'seek_forward',
'seek_backward_large',
'audio_track_next',
'subtitle_track_next',
'chapter_next',
'chapter_previous',
'speed_increase',
'speed_decrease',
'speed_reset',
'sub_seek_next',
'sub_seek_prev',
'skip_marker',
];
for (final action in controlledActions) {
await service.setHotkey(action, const HotKey(key: PhysicalKeyboardKey.f12));
final result = service.handleVideoPlayerKeyEvent(
event,
player,
null,
null,
() => callbacks++,
() => callbacks++,
() => callbacks++,
() => callbacks++,
canControlPlayback: false,
canNavigateMediaItems: true,
onPlayPause: () => callbacks++,
onSkipMarker: () => callbacks++,
onSeekRequested: (_) async => seekCalls++,
);
expect(result, KeyEventResult.handled, reason: action);
}
expect(callbacks, 0);
expect(seekCalls, 0);
expect(player.commands, isEmpty);
expect(settings.read(SettingsService.defaultPlaybackSpeed), initialRate);
});
test('media-item authority is separate and local presentation remains available', () async {
final service = await KeyboardShortcutsService.getInstance();
addTearDown(service.dispose);
final player = _FakePlayer();
var nextCalls = 0;
var localCalls = 0;
const event = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.f12,
logicalKey: LogicalKeyboardKey.f12,
timeStamp: Duration.zero,
);
final muteResult = service.handleVideoPlayerKeyEvent(muteKey, player, null, null, null, null, null, null);
await tester.pumpAndSettle();
for (final action in const ['episode_next', 'episode_previous']) {
await service.setHotkey(action, const HotKey(key: PhysicalKeyboardKey.f12));
expect(
service.handleVideoPlayerKeyEvent(
event,
player,
null,
null,
null,
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: false,
onNextEpisode: () => nextCalls++,
onPreviousEpisode: () => nextCalls++,
),
KeyEventResult.handled,
);
}
expect(nextCalls, 0);
expect(muteResult, KeyEventResult.handled);
expect(player.volume, 0);
expect(settings.read(SettingsService.volume), 37);
final unmuteResult = service.handleVideoPlayerKeyEvent(muteKey, player, null, null, null, null, null, null);
await tester.pumpAndSettle();
expect(unmuteResult, KeyEventResult.handled);
expect(player.volume, 37);
expect(settings.read(SettingsService.volume), 37);
expect(player.volumeChanges, [0, 37]);
for (final action in const [
'fullscreen_toggle',
'subtitle_toggle',
'shader_toggle',
'screenshot',
'zoom_in',
'zoom_out',
'zoom_reset',
]) {
await service.setHotkey(action, const HotKey(key: PhysicalKeyboardKey.f12));
expect(
service.handleVideoPlayerKeyEvent(
event,
player,
() => localCalls++,
() => localCalls++,
null,
null,
null,
null,
canControlPlayback: false,
canNavigateMediaItems: false,
onToggleShader: () => localCalls++,
onScreenshot: () => localCalls++,
onZoomIn: () => localCalls++,
onZoomOut: () => localCalls++,
onZoomReset: () => localCalls++,
),
KeyEventResult.handled,
);
await Future<void>.delayed(Duration.zero);
}
expect(localCalls, 7);
});
test('video zoom scale maps to mpv logarithmic property', () {
@@ -13,6 +13,8 @@ import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import '../test_helpers/backend_client_fixtures.dart';
/// Pins the [LiveTvPlaybackSession] lifecycle on both backends — the
/// per-backend protocol that used to be hand-rolled (3×) inside the player's
@@ -63,7 +65,7 @@ void main() {
PlexClient makeClient(
Future<http.Response> Function(http.Request request) handler, {
List<String>? prioritizedEndpoints,
}) => PlexClient.forTesting(
}) => testPlexClient(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'tok',
@@ -280,5 +282,65 @@ void main() {
// Recovery re-opens the negotiated HLS URL.
expect(await session.recover(directStream: false, directStreamAudio: false), same(session));
});
test('startPlayback propagates status and cancellation failures', () async {
final handlers = <(String, Future<http.Response> Function(http.Request))>[
('401', (_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})),
('500', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})),
('cancelled', (request) async => throw http.RequestAbortedException(request.url)),
];
for (final (name, handler) in handlers) {
final client = JellyfinClient.forTesting(connection: conn(), httpClient: MockClient(handler));
addTearDown(client.close);
await expectLater(
client.liveTv.startPlayback('channel-1'),
throwsA(isA<MediaServerHttpException>()),
reason: name,
);
}
});
test('malformed successful playback data throws distinctly', () async {
final missingSources = JellyfinClient.forTesting(
connection: conn(),
httpClient: MockClient((_) async => jsonResponse({'PlaySessionId': 'play-1'})),
);
addTearDown(missingSources.close);
await expectLater(
missingSources.liveTv.startPlayback('channel-1'),
throwsA(
isA<MediaServerHttpException>()
.having((error) => error.statusCode, 'statusCode', 200)
.having((error) => error.responseData, 'responseData', isNull),
),
);
final malformedSource = JellyfinClient.forTesting(
connection: conn(),
httpClient: MockClient(
(_) async => jsonResponse({
'MediaSources': ['invalid'],
}),
),
);
addTearDown(malformedSource.close);
await expectLater(
malformedSource.liveTv.startPlayback('channel-1'),
throwsA(
isA<PlaybackException>().having((error) => error.reason, 'reason', PlaybackFailureReason.invalidPlaybackData),
),
);
});
test('only a valid empty source list returns no live stream', () async {
final client = JellyfinClient.forTesting(
connection: conn(),
httpClient: MockClient((_) async => jsonResponse({'MediaSources': []})),
);
addTearDown(client.close);
expect(await client.liveTv.startPlayback('channel-1'), isNull);
});
});
}
@@ -0,0 +1,103 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/media_controls_manager.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('com.edde746.os_media_controls/methods');
final calls = <MethodCall>[];
TargetPlatform? previousPlatformOverride;
setUp(() {
previousPlatformOverride = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
calls.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
debugDefaultTargetPlatformOverride = previousPlatformOverride;
});
test('guest, anyone, and host capability snapshots advertise exact authority', () async {
final manager = MediaControlsManager();
addTearDown(manager.dispose);
await manager.setControlsEnabled(
canPlayPause: false,
canGoNext: false,
canGoPrevious: false,
canSeek: false,
canStop: true,
canSkip: false,
canSetSpeed: false,
);
_expectControlTransition(
calls,
enabled: const ['stop'],
disabled: const ['play', 'pause', 'previous', 'next', 'seek', 'skipForward', 'skipBackward', 'changeSpeed'],
);
calls.clear();
await manager.setControlsEnabled(
canPlayPause: true,
canGoNext: false,
canGoPrevious: false,
canSeek: true,
canStop: true,
canSkip: true,
canSetSpeed: true,
);
_expectControlTransition(
calls,
enabled: const ['play', 'pause', 'seek', 'skipForward', 'skipBackward', 'changeSpeed'],
);
calls.clear();
await manager.setControlsEnabled(
canPlayPause: true,
canGoNext: true,
canGoPrevious: true,
canSeek: true,
canStop: true,
canSkip: true,
canSetSpeed: true,
);
_expectControlTransition(calls, enabled: const ['previous', 'next']);
calls.clear();
await manager.setControlsEnabled(
canPlayPause: true,
canGoNext: true,
canGoPrevious: true,
canSeek: true,
canStop: true,
canSkip: true,
canSetSpeed: true,
);
expect(calls, isEmpty);
});
}
void _expectControlTransition(
List<MethodCall> calls, {
List<String> enabled = const [],
List<String> disabled = const [],
}) {
final expectedCalls = <({String method, List<String> controls})>[
if (enabled.isNotEmpty) (method: 'enableControls', controls: enabled),
if (disabled.isNotEmpty) (method: 'disableControls', controls: disabled),
];
expect(calls, hasLength(expectedCalls.length));
for (var index = 0; index < expectedCalls.length; index++) {
expect(calls[index].method, expectedCalls[index].method);
expect(calls[index].arguments, expectedCalls[index].controls);
}
}
@@ -10,6 +10,7 @@ import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_auth_service.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
import '../test_helpers/backend_client_fixtures.dart';
void main() {
// refreshTokensForProfile starts connectivity monitoring after a successful
@@ -24,7 +25,7 @@ void main() {
final manager = MultiServerManager();
addTearDown(manager.dispose);
PlexClient buildClient(String serverId) => PlexClient.forTesting(
PlexClient buildClient(String serverId) => testPlexClient(
config: PlexConfig(
baseUrl: 'http://$serverId:32400',
token: 'old-token',
@@ -34,7 +35,13 @@ void main() {
),
serverId: ServerId(serverId),
serverName: serverId,
httpClient: MockClient((_) async => http.Response('{}', 200, headers: {'content-type': 'application/json'})),
httpClient: MockClient(
(_) async => http.Response(
'{"MediaContainer":{"machineIdentifier":"$serverId"}}',
200,
headers: {'content-type': 'application/json'},
),
),
);
// Both servers already registered and online — refreshTokensForProfile
@@ -58,7 +65,7 @@ void main() {
createdAt: DateTime(2026, 1, 1),
);
final bound = await manager.refreshTokensForProfile(connection);
final bound = await manager.refreshTokensForProfile(connection, profileId: 'profile-a');
// Let the broadcast stream deliver its pending events.
await Future<void>.delayed(Duration.zero);
File diff suppressed because it is too large Load Diff
@@ -479,10 +479,11 @@ class FakeMediaControlsManager extends MediaControlsManager {
bool force = false,
}) async {}
final List<({bool canGoNext, bool canStop, bool canSkip, bool canSetSpeed})> controlSyncs = [];
final List<({bool canPlayPause, bool canGoNext, bool canStop, bool canSkip, bool canSetSpeed})> controlSyncs = [];
@override
Future<void> setControlsEnabled({
bool canPlayPause = false,
bool canGoNext = false,
bool canGoPrevious = false,
bool canSeek = false,
@@ -490,7 +491,13 @@ class FakeMediaControlsManager extends MediaControlsManager {
bool canSkip = false,
bool canSetSpeed = false,
}) async {
controlSyncs.add((canGoNext: canGoNext, canStop: canStop, canSkip: canSkip, canSetSpeed: canSetSpeed));
controlSyncs.add((
canPlayPause: canPlayPause,
canGoNext: canGoNext,
canStop: canStop,
canSkip: canSkip,
canSetSpeed: canSetSpeed,
));
}
@override
@@ -1007,11 +1014,12 @@ void main() {
expect(h.player.seeks.last, Duration.zero);
});
test('music advertises stop and skip but never a speed control', () async {
test('music advertises play, pause, stop, and skip but never a speed control', () async {
await h.playTracks([t1, t2]);
expect(h.controls.controlSyncs, isNotEmpty);
final last = h.controls.controlSyncs.last;
expect(last.canPlayPause, isTrue);
expect(last.canStop, isTrue);
expect(last.canSkip, isTrue);
expect(last.canSetSpeed, isFalse);
@@ -17,6 +17,8 @@ import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/offline_mode_source.dart';
import 'package:plezy/services/offline_watch_sync_service.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/utils/active_client_scope.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
import '../test_helpers/backend_client_fixtures.dart';
@@ -132,6 +134,21 @@ class _ScopedRecordingMediaClient extends _RecordingMediaClient implements Scope
final String scopedServerId;
}
class _RecordingPlexClient extends _RecordingMediaClient implements PlexClient, ScopedMediaServerClient {
_RecordingPlexClient({required super.serverId, required String profileId})
: profileScopeId = buildPlexProfileScopeId(serverId: serverId, profileId: profileId),
super(backend: MediaBackend.plex);
@override
PlexProfileScopeId profileScopeId;
@override
String get scopedServerId => profileScopeId;
@override
Future<void> closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) async {}
}
/// Build a service against an in-memory database and a bare-metal
/// [MultiServerManager] (no servers added).
({OfflineWatchSyncService svc, AppDatabase db, MultiServerManager mgr}) _makeService() {
@@ -272,6 +289,28 @@ void main() {
expect(action!.actionType, 'unwatched');
});
test('concurrent watched then unwatched leaves exactly one unwatched action', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
svc.setActiveProfileId('profile-a');
final watched = svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '42');
final unwatched = svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '42');
await Future.wait([watched, unwatched]);
final rows = (await db.getPendingWatchActions())
.where((row) => row.profileId == 'profile-a' && row.globalKey == 'srv:42')
.toList();
expect(rows, hasLength(1));
expect(rows.single.actionType, 'unwatched');
expect(await svc.getPendingSyncCount(), 1);
expect(await svc.getLocalWatchStatus('srv:42'), isFalse);
});
test('different ratingKeys persist independently', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
@@ -834,6 +873,54 @@ void main() {
});
});
group('Plex scoped sync', () {
test('queues and replays through the exact active Plex profile scope', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
svc.setActiveProfileId('profile-a');
final clientA = _RecordingPlexClient(serverId: ServerId('plex-machine'), profileId: 'profile-a');
mgr.debugRegisterClientForTesting(clientA);
final queuedScope = await svc.queueMarkWatched(serverId: ServerId('plex-machine'), itemId: 'item-1');
expect(queuedScope, clientA.profileScopeId);
expect((await db.getPendingWatchActions()).single.clientScopeId, clientA.profileScopeId);
await svc.syncPendingItems();
expect(clientA.watched, ['item-1']);
expect(await svc.getPendingSyncCount(), 0);
});
test('does not replay a queued Plex owner action through a foreign active profile', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
svc.setActiveProfileId('profile-a');
final scopeA = buildPlexProfileScopeId(serverId: ServerId('plex-machine'), profileId: 'profile-a');
final clientB = _RecordingPlexClient(serverId: ServerId('plex-machine'), profileId: 'profile-b');
mgr.debugRegisterClientForTesting(clientB);
await db.insertWatchAction(
profileId: 'profile-a',
serverId: ServerId('plex-machine'),
clientScopeId: scopeA,
ratingKey: 'item-1',
actionType: OfflineActionType.watched.id,
);
await svc.syncPendingItems();
expect(clientB.watched, isEmpty);
expect(await svc.getPendingSyncCount(), 1);
});
});
group('Jellyfin scoped sync', () {
test('empty active scope falls back to the downloaded scope during client pre-bind', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
+120 -18
View File
@@ -1,36 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_playlist.dart';
import 'package:plezy/models/plex/play_queue_response.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/services/play_queue_launcher.dart';
import 'package:plezy/services/plex_client.dart';
import '../test_helpers/media_items.dart';
// NOTE on coverage scope:
// `PlayQueueLauncher` is almost entirely network/UI glue:
// - every public method calls into [PlexClient.createPlayQueue] or
// [PlexClient.createShowPlayQueue] (network),
// - then setups [PlaybackStateProvider] (Provider),
// - then calls [navigateToVideoPlayer] (Navigator + DownloadProvider +
// SettingsService singleton + Provider).
//
// Without re-implementing that entire dependency tree, the meaningful
// unit-testable surface is:
// - `PlayQueueError` preserves the underlying failure.
// - `launchShuffledShow` short-circuits BEFORE any network call when the
// metadata is not a show or season — that's a pure pre-flight branch.
// - `launchFromCollectionOrPlaylist` short-circuits when the input is
// neither a `PlexMetadata` nor a `PlexPlaylist`.
//
// Everything else (success/empty-queue/error paths) requires a full
// PlexClient fake + a Provider tree + a real Navigator. Skipped.
// Focused orchestration coverage lives here: the network response must be
// published to PlaybackStateProvider before navigation, and a navigation
// failure remains an owned PlayQueueError rather than a reported success.
// Jellyfin cancellation ownership is covered by
// jellyfin_sequential_launcher_test.dart.
class _StubPlexClient implements PlexClient {
_StubPlexClient({this.response});
final PlayQueueResponse? response;
@override
Future<PlayQueueResponse?> createPlayQueue({
String? uri,
int? playlistID,
required String type,
String? key,
int shuffle = 0,
int repeat = 0,
int continuous = 0,
String? librarySectionID,
String? librarySectionTitle,
}) async {
return response;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
Future<BuildContext> _pumpContext(WidgetTester tester) async {
late BuildContext capturedContext;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
capturedContext = context;
return const SizedBox.shrink();
},
),
),
),
);
return capturedContext;
}
PlayQueueResponse _queueWith(MediaItem item) {
return PlayQueueResponse(
playQueueID: 73,
playQueueSelectedItemID: 41,
playQueueShuffled: false,
playQueueTotalCount: 1,
playQueueVersion: 1,
items: [item],
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -45,6 +84,12 @@ void main() {
expect(result.error, same(error));
expect(result, isA<PlayQueueResult>());
});
test('PlayQueueCancelled is a distinct re-exported result', () {
const PlayQueueResult result = PlayQueueCancelled();
expect(result, isA<PlayQueueCancelled>());
expect(result, isNot(isA<PlayQueueError>()));
});
});
// ============================================================
@@ -100,4 +145,61 @@ void main() {
expect(error.toString(), contains('collection or playlist'));
});
});
group('queue application ownership', () {
testWidgets('publishes the Plex queue before navigating to its selected item', (tester) async {
final context = await _pumpContext(tester);
final item = const MediaItem.plex(id: 'movie-1', kind: MediaKind.movie, title: 'Movie', playQueueItemId: 41);
final playbackState = PlaybackStateProvider();
final navigated = <MediaItem>[];
final launcher = PlexPlayQueueLauncher(
context: context,
client: _StubPlexClient(response: _queueWith(item)),
playbackStateForTesting: playbackState,
navigateForTesting: (selected) async {
expect(playbackState.isQueueActive, isTrue);
expect(playbackState.playQueueId, 73);
expect(playbackState.currentQueueItem, same(item));
expect(playbackState.loadedItems.single, same(item));
navigated.add(selected);
},
);
const playlist = MediaPlaylist(id: '12', backend: MediaBackend.plex, title: 'Playlist', playlistType: 'video');
final result = await launcher.launchFromCollectionOrPlaylist(
item: playlist,
shuffle: false,
showLoadingIndicator: false,
);
expect(result, isA<PlayQueueSuccess>());
expect(navigated, hasLength(1));
expect(navigated.single, same(item));
});
testWidgets('navigation failure is returned as PlayQueueError, not success', (tester) async {
final context = await _pumpContext(tester);
final item = const MediaItem.plex(id: 'movie-1', kind: MediaKind.movie, playQueueItemId: 41);
final failure = StateError('navigation failed');
final playbackState = PlaybackStateProvider();
final launcher = PlexPlayQueueLauncher(
context: context,
client: _StubPlexClient(response: _queueWith(item)),
playbackStateForTesting: playbackState,
navigateForTesting: (_) async => throw failure,
);
const playlist = MediaPlaylist(id: '12', backend: MediaBackend.plex, title: 'Playlist', playlistType: 'video');
final result = await launcher.launchFromCollectionOrPlaylist(
item: playlist,
shuffle: false,
showLoadingIndicator: false,
);
expect(result, isA<PlayQueueError>());
expect((result as PlayQueueError).error, same(failure));
expect(playbackState.isQueueActive, isTrue);
expect(playbackState.currentQueueItem, same(item));
});
});
}
+263 -22
View File
@@ -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:plezy/database/app_database.dart';
import 'package:plezy/media/media_backend.dart';
@@ -15,32 +16,14 @@ import 'package:plezy/services/offline_watch_sync_service.dart';
import 'package:plezy/services/playback_progress_tracker.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
import 'package:plezy/utils/active_client_scope.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
// NOTE on coverage scope:
// `PlaybackProgressTracker` periodically samples the player's position and
// reports it to either an online [PlexClient] or the offline queue. The
// periodic [Timer] is purely a wall-clock concern — instead of trying to
// virtualize it, we exercise the routing/threshold/scrobble logic directly
// through the public [PlaybackProgressTracker.sendProgress].
//
// Coverage:
// - Constructor invariants (offline ↔ offlineWatchService, online ↔ client).
// - Online routing: 'stopped' awaits, 'playing'/'paused' fire-and-forget.
// - Threshold gating: scrobbles once when percent >= server threshold.
// - Scrobble idempotency: a second sendProgress past threshold is a no-op.
// - Offline routing: queues a progress update via the database.
// - Offline progress with null serverId is a no-op (no queue write).
// - 'stopped' event emits a WatchStateNotifier.notifyProgress.
// - dispose() / stopTracking() are idempotent.
//
// What is NOT covered (by design):
// - The periodic [Timer.periodic] tick itself — we'd need to either drive
// real time (flaky) or inject a clock dependency (out of scope).
// - The exponential-backoff state — observable only across multiple ticks
// under wall time.
// Periodic behavior is virtualized with fake_async and the tracker's existing
// updateInterval seam. Routing, threshold, scrobble, cadence, coalescing,
// backoff, resume, and disposal are asserted through observable calls.
/// Fake Player whose state is mutable from the test.
class _FakePlayer implements Player {
@@ -118,6 +101,11 @@ class _FakePlexClient implements PlexClient {
/// [serverId] after the transport call.
@override
ServerId get serverId => ServerId('scrobbler');
@override
PlexProfileScopeId profileScopeId = buildPlexProfileScopeId(serverId: ServerId('scrobbler'), profileId: 'profile-a');
@override
String get scopedServerId => profileScopeId;
@override
double get watchedThreshold => thresholdPercent / 100.0;
@@ -279,6 +267,81 @@ class _DelayedStartClient extends _FakePlexClient {
}
}
class _DelayedProgressClient extends _FakePlexClient {
final List<int> progressAttempts = [];
final List<Completer<void>> progressGates = [];
@override
Future<void> reportPlaybackProgress({
required String itemId,
required Duration position,
required Duration duration,
bool isPaused = false,
String? playSessionId,
String? playMethod,
String? liveStreamId,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {
progressAttempts.add(position.inMilliseconds);
final gate = Completer<void>();
progressGates.add(gate);
await gate.future;
await super.reportPlaybackProgress(
itemId: itemId,
position: position,
duration: duration,
isPaused: isPaused,
playSessionId: playSessionId,
playMethod: playMethod,
liveStreamId: liveStreamId,
mediaSourceId: mediaSourceId,
audioStreamIndex: audioStreamIndex,
subtitleStreamIndex: subtitleStreamIndex,
);
}
}
class _FailingProgressClient extends _FakePlexClient {
_FailingProgressClient({required this.failuresRemaining});
int failuresRemaining;
int progressAttempts = 0;
@override
Future<void> reportPlaybackProgress({
required String itemId,
required Duration position,
required Duration duration,
bool isPaused = false,
String? playSessionId,
String? playMethod,
String? liveStreamId,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {
progressAttempts++;
if (failuresRemaining > 0) {
failuresRemaining--;
throw StateError('planned progress failure');
}
await super.reportPlaybackProgress(
itemId: itemId,
position: position,
duration: duration,
isPaused: isPaused,
playSessionId: playSessionId,
playMethod: playMethod,
liveStreamId: liveStreamId,
mediaSourceId: mediaSourceId,
audioStreamIndex: audioStreamIndex,
subtitleStreamIndex: subtitleStreamIndex,
);
}
}
/// Jellyfin-style backend: the playback-stopped report marks the item played
/// server-side, so the in-player scrobble path must emit only the local watch
/// event and skip the explicit server mark (#1287).
@@ -979,6 +1042,7 @@ void main() {
final progressEvents = events.where((e) => e.changeType == WatchStateChangeType.progressUpdate).toList();
expect(progressEvents, isNotEmpty);
expect(progressEvents.first.viewOffset, 30000);
expect(progressEvents.first.cacheServerId, client.profileScopeId);
});
test('does NOT emit on "stopped" if position is 0 (no real watch)', () async {
@@ -1031,6 +1095,178 @@ void main() {
});
});
group('periodic tracking', () {
test('reports immediately, follows cadence, and resumes playing after pause', () {
fakeAsync((async) {
final client = _FakePlexClient();
final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100));
var pausedKeepalives = 0;
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(),
player: player,
isOffline: false,
updateInterval: const Duration(seconds: 1),
onPausedKeepalive: () async => pausedKeepalives++,
);
tracker.startTracking();
async.flushMicrotasks();
expect(client.updateProgressCalls.map((call) => call.state), ['playing']);
async.elapse(const Duration(milliseconds: 999));
async.flushMicrotasks();
expect(client.updateProgressCalls, hasLength(1));
async.elapse(const Duration(milliseconds: 1));
async.flushMicrotasks();
expect(client.updateProgressCalls.map((call) => call.state), ['playing', 'playing']);
player.playing = false;
async.elapse(const Duration(seconds: 1));
async.flushMicrotasks();
expect(client.updateProgressCalls.map((call) => call.state), ['playing', 'playing', 'paused']);
expect(pausedKeepalives, 1);
player.playing = true;
async.elapse(const Duration(seconds: 1));
async.flushMicrotasks();
expect(client.updateProgressCalls.map((call) => call.state), ['playing', 'playing', 'paused', 'playing']);
expect(pausedKeepalives, 1);
tracker.dispose();
});
});
test('coalesces timer ticks while a progress report is in flight', () {
fakeAsync((async) {
final client = _DelayedProgressClient();
final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(),
player: player,
isOffline: false,
updateInterval: const Duration(seconds: 1),
);
tracker.startTracking();
async.flushMicrotasks();
expect(client.updateProgressCalls, hasLength(1));
player.position = const Duration(seconds: 10);
async.elapse(const Duration(seconds: 1));
async.flushMicrotasks();
expect(client.progressAttempts, [10000]);
player.position = const Duration(seconds: 20);
async.elapse(const Duration(seconds: 1));
async.flushMicrotasks();
player.position = const Duration(seconds: 30);
async.elapse(const Duration(seconds: 1));
async.flushMicrotasks();
expect(client.progressAttempts, [10000]);
client.progressGates.first.complete();
async.flushMicrotasks();
expect(client.progressAttempts, [10000, 30000]);
client.progressGates.last.complete();
async.flushMicrotasks();
expect(client.updateProgressCalls.map((call) => call.time), [5000, 10000, 30000]);
tracker.dispose();
});
});
test('backs off by one then two ticks and resumes after success', () {
fakeAsync((async) {
final client = _FailingProgressClient(failuresRemaining: 2);
final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(),
player: player,
isOffline: false,
updateInterval: const Duration(seconds: 1),
);
tracker.startTracking();
async.flushMicrotasks();
expect(client.updateProgressCalls, hasLength(1));
async.elapse(const Duration(seconds: 1));
async.flushMicrotasks();
expect(client.progressAttempts, 1);
async.elapse(const Duration(seconds: 1));
async.flushMicrotasks();
expect(client.progressAttempts, 1);
async.elapse(const Duration(seconds: 1));
async.flushMicrotasks();
expect(client.progressAttempts, 2);
async.elapse(const Duration(seconds: 2));
async.flushMicrotasks();
expect(client.progressAttempts, 2);
async.elapse(const Duration(seconds: 1));
async.flushMicrotasks();
expect(client.progressAttempts, 3);
expect(client.updateProgressCalls, hasLength(2));
async.elapse(const Duration(seconds: 1));
async.flushMicrotasks();
expect(client.progressAttempts, 4);
expect(client.updateProgressCalls, hasLength(3));
tracker.dispose();
});
});
test('dispose cancels future periodic reports', () {
fakeAsync((async) {
final client = _FakePlexClient();
final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(),
player: player,
isOffline: false,
updateInterval: const Duration(seconds: 1),
);
tracker.startTracking();
async.flushMicrotasks();
expect(client.updateProgressCalls, hasLength(1));
tracker.dispose();
async.elapse(const Duration(minutes: 1));
async.flushMicrotasks();
expect(client.updateProgressCalls, hasLength(1));
});
});
});
test('resumeAfterStoppedReport opens a fresh reporting session', () async {
final client = _FakePlexClient();
final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false);
addTearDown(tracker.dispose);
await tracker.sendStoppedProgressOnce();
await tracker.sendStoppedProgressOnce();
expect(client.updateProgressCalls.map((call) => call.state), ['stopped']);
tracker.resumeAfterStoppedReport();
await tracker.sendProgress('playing');
await Future<void>.delayed(Duration.zero);
await tracker.sendStoppedProgressOnce();
expect(client.updateProgressCalls.map((call) => call.state), ['stopped', 'playing', 'stopped']);
});
// ============================================================
// startTracking / stopTracking / dispose lifecycle
// ============================================================
@@ -1097,6 +1333,11 @@ class _ScrobblePreciseClient implements PlexClient {
/// still registers as a failed scrobble.
@override
ServerId get serverId => ServerId('scrobbler');
@override
PlexProfileScopeId profileScopeId = buildPlexProfileScopeId(serverId: ServerId('scrobbler'), profileId: 'profile-a');
@override
String get scopedServerId => profileScopeId;
@override
int get watchedThresholdPercent => thresholdPercent;
+91 -1
View File
@@ -9,6 +9,7 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/services/api_cache.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/utils/active_client_scope.dart';
import '../test_helpers/media_items.dart';
void main() {
@@ -33,12 +34,22 @@ void main() {
String title = 'Item',
Object? librarySectionID,
String? librarySectionTitle,
int? viewCount,
int? viewOffset,
int? lastViewedAt,
}) => {
'MediaContainer': {
'librarySectionID': ?librarySectionID,
'librarySectionTitle': ?librarySectionTitle,
'Metadata': [
{'ratingKey': ratingKey, 'title': title, 'type': 'movie'},
{
'ratingKey': ratingKey,
'title': title,
'type': 'movie',
'viewCount': ?viewCount,
'viewOffset': ?viewOffset,
'lastViewedAt': ?lastViewedAt,
},
],
},
};
@@ -115,6 +126,23 @@ void main() {
expect(hit, equals(payload));
});
test('transfer namespace maps cached metadata back to the public server identity', () async {
final transferScope = buildPlexTransferScopeId(ServerId('srv'));
await cache.put(
transferScope.cacheServerId,
'/library/metadata/1',
mediaContainer(ratingKey: '1', title: 'Transferred'),
);
await cache.pinForOffline(transferScope.cacheServerId, '1');
final item = await cache.getMetadata(transferScope.cacheServerId, '1');
final all = await cache.getAllPinnedMetadata(cacheServerIds: {transferScope.cacheServerId});
expect(item?.serverId, 'srv');
expect(item?.globalKey, 'srv:1');
expect(all.keys, {'srv:1'});
});
test('put on existing key overwrites prior data (insertOnConflictUpdate)', () async {
await cache.put(ServerId('srv'), '/library/metadata/1', {
'MediaContainer': {
@@ -399,5 +427,67 @@ void main() {
expect(result.keys, contains('srv:good'));
expect(result.keys, isNot(contains('srv:bad')));
});
test('profile-scoped rows stay isolated while projecting the public item identity', () async {
final publicServerId = ServerId('plex-public');
final scopeA = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-a').cacheServerId;
final scopeB = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-b').cacheServerId;
const ratingKey = '42';
const endpoint = '/library/metadata/$ratingKey';
await cache.put(
scopeA,
endpoint,
mediaContainer(
ratingKey: ratingKey,
title: 'Profile A',
viewCount: 0,
viewOffset: 12000,
lastViewedAt: 1700000001,
),
);
await cache.put(
scopeB,
endpoint,
mediaContainer(ratingKey: ratingKey, title: 'Profile B', viewCount: 1, viewOffset: 0, lastViewedAt: 1700000002),
);
await cache.pinForOffline(scopeA, ratingKey);
await cache.pinForOffline(scopeB, ratingKey);
final singleA = await cache.getMetadata(scopeA, ratingKey);
final singleB = await cache.getMetadata(scopeB, ratingKey);
expect(singleA, isNotNull);
expect(singleA!.serverId, 'plex-public');
expect(singleA.globalKey, 'plex-public:42');
expect(singleA.isWatched, isFalse);
expect(singleA.viewOffsetMs, 12000);
expect(singleA.lastViewedAt, 1700000001);
expect(singleB, isNotNull);
expect(singleB!.serverId, 'plex-public');
expect(singleB.globalKey, 'plex-public:42');
expect(singleB.isWatched, isTrue);
expect(singleB.viewOffsetMs, 0);
expect(singleB.lastViewedAt, 1700000002);
final bulkA = await cache.getAllPinnedMetadata(cacheServerIds: {scopeA});
final bulkB = await cache.getAllPinnedMetadata(cacheServerIds: {scopeB});
expect(bulkA.keys, ['plex-public:42']);
expect(bulkA['plex-public:42']!.title, 'Profile A');
expect(bulkA['plex-public:42']!.viewOffsetMs, 12000);
expect(bulkB.keys, ['plex-public:42']);
expect(bulkB['plex-public:42']!.title, 'Profile B');
expect(bulkB['plex-public:42']!.isWatched, isTrue);
await cache.clearVolatile();
expect(await cache.getMetadata(scopeA, ratingKey), isNotNull);
expect(await cache.getMetadata(scopeB, ratingKey), isNotNull);
expect(await cache.isPinnedRatingKey(scopeA, ratingKey), isTrue);
expect(await cache.isPinnedRatingKey(scopeB, ratingKey), isTrue);
await cache.unpinForOffline(scopeA, ratingKey);
await cache.deleteForItem(scopeA, ratingKey);
expect(await cache.getMetadata(scopeA, ratingKey), isNull);
expect(await cache.getMetadata(scopeB, ratingKey), isNotNull);
expect(await cache.isPinnedRatingKey(scopeB, ratingKey), isTrue);
});
});
}
+428 -24
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:async';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -6,9 +7,12 @@ import 'package:http/http.dart' as http;
import 'package:plezy/database/app_database.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/utils/active_client_scope.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/media_items.dart';
@@ -23,28 +27,175 @@ void main() {
tearDown(() => db.close());
final publicServerId = ServerId('server-id');
final defaultProfileScopeId = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'test-profile');
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
testPlexClient(serverId: ServerId('server-id'), handler: handler);
testPlexClient(serverId: publicServerId, profileScopeId: defaultProfileScopeId, handler: handler);
test('void mutations surface non-success responses', () async {
final client = makeClient((_) async => http.Response('rejected', 500));
addTearDown(client.close);
group('Plex mutation result families', () {
test('void mutation completes on success and preserves status/transport failures', () async {
final item = testMediaItem(
id: 'item-id',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: 'server-id',
);
final success = makeClient((_) async => http.Response('', 200));
addTearDown(success.close);
await success.markWatched(item);
for (final mutation in <Future<void> Function()>[
() => client.cancelActivity('activity-id'),
() => client.removeFromOnDeck('item-id'),
() => client.emptyLibraryTrash('library-id'),
]) {
await expectLater(mutation(), throwsA(isA<MediaServerHttpException>()));
}
});
for (final status in [400, 500]) {
final failing = makeClient((_) async => http.Response('{}', status));
addTearDown(failing.close);
await expectLater(
failing.markWatched(item),
throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', status)),
);
}
test('nullable creation APIs reject non-success response bodies', () async {
final client = makeClient((_) async => http.Response('rejected', 500));
addTearDown(client.close);
final timeout = makeClient((_) async => throw TimeoutException('timed out'));
addTearDown(timeout.close);
await expectLater(
timeout.markWatched(item),
throwsA(
isA<MediaServerHttpException>().having(
(error) => error.type,
'type',
MediaServerHttpErrorType.connectionTimeout,
),
),
);
});
expect(await client.createCollectionFromUri(sectionId: '1', title: 'Collection', uri: 'server://items'), isNull);
expect(await client.createPlayQueue(uri: 'server://items', type: 'video'), isNull);
test('nullable collection creation throws request failures and reserves null for unusable metadata', () async {
for (final status in [400, 500]) {
final failing = makeClient((_) async => http.Response('{}', status));
addTearDown(failing.close);
await expectLater(
failing.createCollection(libraryId: '1', title: 'Collection', items: const []),
throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', status)),
);
}
final timeout = makeClient((_) async => throw TimeoutException('timed out'));
addTearDown(timeout.close);
await expectLater(
timeout.createCollection(libraryId: '1', title: 'Collection', items: const []),
throwsA(isA<MediaServerHttpException>()),
);
final unusable = makeClient(
(_) async => http.Response(
jsonEncode({
'MediaContainer': {'Metadata': []},
}),
200,
headers: {'content-type': 'application/json'},
),
);
addTearDown(unusable.close);
expect(await unusable.createCollection(libraryId: '1', title: 'Collection', items: const []), isNull);
final valid = makeClient(
(_) async => http.Response(
jsonEncode({
'MediaContainer': {
'Metadata': [
{'ratingKey': 'collection-1'},
],
},
}),
200,
headers: {'content-type': 'application/json'},
),
);
addTearDown(valid.close);
expect(await valid.createCollection(libraryId: '1', title: 'Collection', items: const []), 'collection-1');
});
test('nullable playlist creation shares the request and accepted-null contract', () async {
final valid = makeClient(
(_) async => http.Response(
jsonEncode({
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'playlist-1',
'type': 'playlist',
'playlistType': 'video',
'title': 'Playlist',
'smart': false,
},
],
},
}),
200,
headers: {'content-type': 'application/json'},
),
);
addTearDown(valid.close);
expect((await valid.createPlaylist(title: 'Playlist', items: const []))?.id, 'playlist-1');
final unusable = makeClient(
(_) async => http.Response(
jsonEncode({
'MediaContainer': {'Metadata': []},
}),
200,
headers: {'content-type': 'application/json'},
),
);
addTearDown(unusable.close);
expect(await unusable.createPlaylist(title: 'Playlist', items: const []), isNull);
final failing = makeClient((_) async => http.Response('{}', 500));
addTearDown(failing.close);
await expectLater(
failing.createPlaylist(title: 'Playlist', items: const []),
throwsA(isA<MediaServerHttpException>()),
);
});
test('playlist move returns false only for local preconditions and throws request failures', () async {
var requests = 0;
final localOnly = makeClient((_) async {
requests++;
return http.Response('', 200);
});
addTearDown(localOnly.close);
final generic = testMediaItem(
id: 'item',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: 'server-id',
);
const missingEntry = PlexMediaItem(id: 'item', kind: MediaKind.movie);
expect(
await localOnly.movePlaylistItem(playlistId: 'playlist', item: generic, newIndex: 0, afterItem: null),
isFalse,
);
expect(
await localOnly.movePlaylistItem(playlistId: 'playlist', item: missingEntry, newIndex: 0, afterItem: null),
isFalse,
);
expect(requests, 0);
const validEntry = PlexMediaItem(id: 'item', kind: MediaKind.movie, playlistItemId: 7);
final success = makeClient((_) async => http.Response('', 200));
addTearDown(success.close);
expect(
await success.movePlaylistItem(playlistId: 'playlist', item: validEntry, newIndex: 0, afterItem: null),
isTrue,
);
final failing = makeClient((_) async => http.Response('{}', 500));
addTearDown(failing.close);
await expectLater(
failing.movePlaylistItem(playlistId: 'playlist', item: validEntry, newIndex: 0, afterItem: null),
throwsA(isA<MediaServerHttpException>()),
);
});
});
test('play queue accepts numeric strings from Plex', () async {
@@ -214,7 +365,7 @@ void main() {
test('lyrics refresh incomplete cached metadata and prefer LRC streams', () async {
const metadataEndpoint = '/library/metadata/track-1';
await PlexApiCache.instance.put(ServerId('server-id'), metadataEndpoint, {
await PlexApiCache.instance.put(defaultProfileScopeId.cacheServerId, metadataEndpoint, {
'MediaContainer': {
'Metadata': [
{'ratingKey': 'track-1', 'type': 'track'},
@@ -300,7 +451,7 @@ void main() {
);
expect(requestCount, 1);
expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), isNull);
expect(await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpoint), isNull);
}
});
@@ -316,7 +467,7 @@ void main() {
],
},
};
await PlexApiCache.instance.put(ServerId('server-id'), endpoint, cachedResponse);
await PlexApiCache.instance.put(defaultProfileScopeId.cacheServerId, endpoint, cachedResponse);
var requestCount = 0;
final client = makeClient((request) async {
requestCount++;
@@ -341,7 +492,7 @@ void main() {
expect(requestCount, 1);
expect(children.map((child) => child.id), ['cached-child']);
expect(children.single.title, 'Cached Season');
expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), cachedResponse);
expect(await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpoint), cachedResponse);
});
test('successful child fetch parses and caches the response', () async {
@@ -368,7 +519,7 @@ void main() {
expect(requestCount, 1);
expect(children.map((child) => child.id), ['fresh-child']);
expect(children.single.title, 'Fresh Season');
expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), responseData);
expect(await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpoint), responseData);
});
test('child retrieval walks every page and caches the combined result', () async {
@@ -397,7 +548,7 @@ void main() {
addTearDown(client.close);
final children = await client.fetchChildren(parentId);
final cached = await PlexApiCache.instance.get(ServerId('server-id'), endpoint);
final cached = await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpoint);
final cachedContainer = cached!['MediaContainer'] as Map<String, dynamic>;
final cachedMetadata = cachedContainer['Metadata'] as List<dynamic>;
@@ -442,7 +593,7 @@ void main() {
final albums = await client.fetchArtistAlbums(
testMediaItem(id: 'artist-1', kind: MediaKind.artist, libraryId: '7'),
);
final cached = await PlexApiCache.instance.get(ServerId('server-id'), cacheKey);
final cached = await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, cacheKey);
final cachedContainer = cached!['MediaContainer'] as Map<String, dynamic>;
final cachedMetadata = cachedContainer['Metadata'] as List<dynamic>;
@@ -503,4 +654,257 @@ void main() {
expect(requestedPaths, ['/library/metadata/artist-1', '/library/sections/7/all']);
expect(albums.map((album) => album.id), ['album-1']);
});
test('profile transition isolates metadata and every direct cache-only bypass', () async {
final scopeA = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-a');
final scopeB = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-b');
const metadataEndpoint = '/library/metadata/42';
const bypassEndpoint = '/library/metadata/bypass';
const tokenA = 'synthetic-token-a';
const tokenB = 'synthetic-token-b';
final requests = <({String path, String? token})>[];
String? tokenFor(http.Request request) {
for (final entry in request.headers.entries) {
if (entry.key.toLowerCase() == 'x-plex-token') return entry.value;
}
return null;
}
Map<String, dynamic> metadataPayload(
String ratingKey,
String title, {
required int markerId,
required int audioTrackId,
}) {
return {
'MediaContainer': {
'Metadata': [
{
'ratingKey': ratingKey,
'type': 'movie',
'title': title,
'duration': 120000,
'Marker': [
{'id': markerId, 'type': 'intro', 'startTimeOffset': 1000, 'endTimeOffset': 2000},
],
'Media': [
{
'id': 1,
'videoResolution': '1080',
'Part': [
{
'id': 10,
'key': '/library/parts/10/file.mkv',
'Stream': [
{'id': audioTrackId, 'streamType': 2, 'codec': 'aac'},
],
},
],
},
],
},
],
},
};
}
final client = testPlexClient(
token: tokenA,
serverId: publicServerId,
profileScopeId: scopeA,
handler: (request) async {
final token = tokenFor(request);
requests.add((path: request.url.path, token: token));
if (request.url.path == '/') {
return http.Response(
jsonEncode({
'MediaContainer': {'machineIdentifier': publicServerId.value},
}),
200,
headers: const {'content-type': 'application/json'},
);
}
if (request.url.path == '/media/providers') {
return http.Response(
jsonEncode({
'MediaContainer': {'MediaProvider': <Object>[]},
}),
200,
headers: const {'content-type': 'application/json'},
);
}
if (request.url.path == metadataEndpoint) {
final payload = token == tokenA
? metadataPayload('42', 'Profile A network', markerId: 101, audioTrackId: 11)
: metadataPayload('42', 'Profile B network', markerId: 202, audioTrackId: 22);
return http.Response(jsonEncode(payload), 200, headers: const {'content-type': 'application/json'});
}
return http.Response('', 200);
},
);
addTearDown(client.close);
final itemA = await client.fetchItem('42');
expect(itemA, isNotNull);
expect(itemA!.title, 'Profile A network');
expect(itemA.serverId, 'server-id');
expect(itemA.globalKey, 'server-id:42');
await client.applyProfileUpdate(newToken: tokenB, newProfileScopeId: scopeB);
final itemB = await client.fetchItem('42');
expect(itemB, isNotNull);
expect(itemB!.title, 'Profile B network');
expect(itemB.serverId, 'server-id');
expect(itemB.globalKey, 'server-id:42');
final cachedA = await PlexApiCache.instance.getMetadata(scopeA.cacheServerId, '42');
final cachedB = await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '42');
expect(cachedA?.title, 'Profile A network');
expect(cachedB?.title, 'Profile B network');
expect(requests.where((request) => request.path == metadataEndpoint).map((request) => request.token), [
tokenA,
tokenB,
]);
expect(requests.where((request) => request.path == '/media/providers').map((request) => request.token), [tokenB]);
await PlexApiCache.instance.put(
scopeA.cacheServerId,
bypassEndpoint,
metadataPayload('bypass', 'Profile A bypass', markerId: 101, audioTrackId: 11),
);
await PlexApiCache.instance.put(
scopeB.cacheServerId,
bypassEndpoint,
metadataPayload('bypass', 'Profile B bypass', markerId: 202, audioTrackId: 22),
);
final extras = await client.fetchPlaybackExtrasFromCacheOnly('bypass');
final mediaSource = await client.fetchCachedMediaSourceInfo('bypass');
expect(extras, isNotNull);
expect(extras!.markers.single.id, 202);
expect(mediaSource, isNotNull);
expect(mediaSource!.audioTracks.single.id, 22);
expect(
await client.updateMetadata(sectionId: 1, ratingKey: 'bypass', typeNumber: 1, title: 'Profile B renamed'),
isTrue,
);
expect(await PlexApiCache.instance.get(scopeB.cacheServerId, bypassEndpoint), isNull);
expect(await PlexApiCache.instance.get(scopeA.cacheServerId, bypassEndpoint), isNotNull);
});
test('cache-first miss keeps the sending profile identity and cache scope together', () async {
final scopeA = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-a');
final scopeB = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-b');
const tokenA = 'synthetic-token-a';
const tokenB = 'synthetic-token-b';
const endpoint = '/library/metadata/gated';
final requests = <({String path, String? token})>[];
String? tokenFor(http.Request request) {
for (final entry in request.headers.entries) {
if (entry.key.toLowerCase() == 'x-plex-token') return entry.value;
}
return null;
}
Map<String, dynamic> payload(String owner, int markerId) => {
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'gated',
'type': 'movie',
'title': owner,
'Marker': [
{'id': markerId, 'type': 'intro', 'startTimeOffset': 1000, 'endTimeOffset': 2000},
],
},
],
},
};
final client = testPlexClient(
token: tokenA,
serverId: publicServerId,
profileScopeId: scopeA,
handler: (request) async {
final token = tokenFor(request);
requests.add((path: request.url.path, token: token));
if (request.url.path == '/') {
return http.Response(
jsonEncode({
'MediaContainer': {'machineIdentifier': publicServerId.value},
}),
200,
headers: const {'content-type': 'application/json'},
);
}
if (request.url.path == '/media/providers') {
return http.Response(
jsonEncode({
'MediaContainer': {'MediaProvider': <Object>[]},
}),
200,
headers: const {'content-type': 'application/json'},
);
}
if (request.url.path == endpoint) {
final response = token == tokenA ? payload('Profile A response', 101) : payload('Profile B response', 202);
return http.Response(jsonEncode(response), 200, headers: const {'content-type': 'application/json'});
}
return http.Response('not found', 404);
},
);
addTearDown(client.close);
final releaseCacheRead = Completer<void>();
final transactionStarted = Completer<void>();
final heldTransaction = db.transaction(() async {
transactionStarted.complete();
await releaseCacheRead.future;
});
await transactionStarted.future;
final extrasFuture = client.getPlaybackExtras('gated');
try {
await client.applyProfileUpdate(newToken: tokenB, newProfileScopeId: scopeB);
} finally {
releaseCacheRead.complete();
}
await heldTransaction;
final extras = await extrasFuture;
expect(extras.markers.single.id, 101);
expect(requests.where((request) => request.path == endpoint).map((request) => request.token), [tokenA]);
expect(await PlexApiCache.instance.get(scopeA.cacheServerId, endpoint), payload('Profile A response', 101));
expect(await PlexApiCache.instance.get(scopeB.cacheServerId, endpoint), isNull);
});
}
class _AbortAwareActivitiesClient extends http.BaseClient {
final requestStarted = Completer<void>();
final abortObserved = Completer<void>();
final _response = Completer<http.StreamedResponse>();
var requestCount = 0;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
requestCount++;
if (!requestStarted.isCompleted) {
requestStarted.complete();
}
final abortTrigger = (request as http.Abortable).abortTrigger!;
unawaited(
abortTrigger.then((_) {
if (!abortObserved.isCompleted) {
abortObserved.complete();
}
if (!_response.isCompleted) {
_response.completeError(http.RequestAbortedException(request.url));
}
}),
);
return _response.future;
}
}
+10
View File
@@ -9,6 +9,7 @@ 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);
@@ -79,6 +80,7 @@ void main() {
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
);
@@ -110,6 +112,7 @@ void main() {
languageCode: 'fr',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
);
@@ -140,6 +143,7 @@ void main() {
languageCode: 'en',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
);
@@ -175,6 +179,7 @@ void main() {
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
prioritizedEndpoints: const [primary, fallback],
@@ -207,6 +212,7 @@ void main() {
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
prioritizedEndpoints: const [primary, fallback],
@@ -240,6 +246,7 @@ void main() {
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
seedTranscoderVideoSupport: true,
@@ -272,6 +279,7 @@ void main() {
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
seedTranscoderVideoSupport: true,
@@ -303,6 +311,7 @@ void main() {
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
);
@@ -338,6 +347,7 @@ void main() {
version: 'test',
),
serverId: ServerId('server-id'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
serverName: 'Server',
httpClient: httpClient,
prioritizedEndpoints: const [primary, fallback],
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:plezy/media/ids.dart';
@@ -6,11 +7,13 @@ import 'package:drift/native.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/models/media_subscription.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';
void main() {
late AppDatabase db;
@@ -42,6 +45,7 @@ void main() {
machineIdentifier: 'machine-1',
),
serverId: ServerId('machine-1'),
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('machine-1'), profileId: 'profile-a'),
httpClient: MockClient(handler),
epgProviders: epgProviders,
);
@@ -81,6 +85,51 @@ void main() {
expect(a.liveTv.favoriteStoreKey, b.liveTv.favoriteStoreKey);
});
test('favorite read preserves a successful empty response', () async {
final client = makeClient((request) async {
expect(request.url.path, '/settings/favoriteChannels');
return jsonResponse({'MediaContainer': <String, dynamic>{}});
});
addTearDown(client.close);
await expectLater(client.liveTv.fetchFavoriteChannels(), completion(isEmpty));
});
test('favorite read propagates HTTP errors', () async {
final client = makeClient((request) async {
expect(request.url.path, '/settings/favoriteChannels');
return http.Response('service unavailable', 503);
});
addTearDown(client.close);
await expectLater(
client.liveTv.fetchFavoriteChannels(),
throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', 503)),
);
});
test('favorite write propagates HTTP errors to the mutation caller', () async {
final requestStarted = Completer<void>();
final releaseResponse = Completer<void>();
final client = makeClient((request) async {
expect(request.method, 'PUT');
expect(request.url.path, '/settings/favoriteChannels');
requestStarted.complete();
await releaseResponse.future;
return http.Response('service unavailable', 503);
});
addTearDown(client.close);
final mutation = client.liveTv.setFavoriteChannels(const []);
await requestStarted.future;
releaseResponse.complete();
await expectLater(
mutation,
throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', 503)),
);
});
test('DVR list applies root channel mapping to each DVR and parses string numbers', () async {
final client = makeClient((request) async {
expect(request.url.path, '/livetv/dvrs');
+119
View File
@@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
@@ -5,11 +8,127 @@ import 'package:plezy/media/media_display_criteria.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_stream.dart';
import 'package:plezy/services/plex_mappers.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
const _serverId = 'plex-machine-1';
const _serverName = 'Home';
void _expectSafePlexMapperEvent(
SentryEvent event, {
required Iterable<String> responseMarkers,
required int topLevelFieldCount,
}) {
final eventJson = event.toJson();
final serializedEvent = jsonEncode(eventJson);
for (final marker in responseMarkers) {
expect(serializedEvent, isNot(contains(marker)), reason: marker);
}
final contexts = Map<String, dynamic>.from(eventJson['contexts'] as Map);
expect(contexts.containsKey('json'), isFalse);
expect(Map<String, dynamic>.from(contexts['plex_mapper'] as Map), {
'backend': 'plex',
'dto': 'PlexMetadataDto',
'topLevelFieldCount': topLevelFieldCount,
});
final exception = Map<String, dynamic>.from(((eventJson['exception'] as Map)['values'] as List).single as Map);
expect(exception['type'], contains('TypeError'));
final stackTrace = Map<String, dynamic>.from(exception['stacktrace'] as Map);
expect(stackTrace['frames'], isNotEmpty);
}
void main() {
group('Plex metadata Sentry diagnostics', () {
late Completer<SentryEvent> capturedEvent;
setUp(() async {
capturedEvent = Completer<SentryEvent>();
await Sentry.init((options) {
options
..dsn = 'https://public@example.com/1'
..beforeSend = (event, hint) {
capturedEvent.complete(event);
return null;
};
});
addTearDown(Sentry.close);
});
test('captures only a closed projection and rethrows malformed metadata', () async {
const responseMarkers = [
'cc004-rating-marker',
'cc004-title-marker',
'cc004-summary-marker',
'cc004-unmodeled-key-marker',
'cc004-nested-marker',
'cc004-file-marker',
];
final malformedMetadata = <String, dynamic>{
'ratingKey': responseMarkers[0],
'title': responseMarkers[1],
'summary': responseMarkers[2],
responseMarkers[3]: {'value': responseMarkers[4]},
'Media': [
{
'id': 1,
'Part': [
{'id': 1, 'file': responseMarkers[5]},
],
},
],
'guid': 7,
};
expect(() => PlexMetadataDto.fromJson(malformedMetadata), throwsA(isA<TypeError>()));
final event = await capturedEvent.future;
_expectSafePlexMapperEvent(event, responseMarkers: responseMarkers, topLevelFieldCount: malformedMetadata.length);
});
test('captures diagnostics while a hub omits only the malformed sibling', () async {
const responseMarkers = [
'cc004-hub-rating-marker',
'cc004-hub-title-marker',
'cc004-hub-summary-marker',
'cc004-hub-unmodeled-key-marker',
'cc004-hub-nested-marker',
'cc004-hub-file-marker',
];
final malformedMetadata = <String, dynamic>{
'ratingKey': responseMarkers[0],
'title': responseMarkers[1],
'summary': responseMarkers[2],
responseMarkers[3]: {'value': responseMarkers[4]},
'Media': [
{
'id': 1,
'Part': [
{'id': 1, 'file': responseMarkers[5]},
],
},
],
'guid': 7,
};
final hub = PlexMappers.mediaHubFromJson({
'key': '/hubs/cc004',
'title': 'Synthetic hub',
'type': 'movie',
'Metadata': [
{'ratingKey': 'valid-sibling', 'type': 'movie', 'title': 'Valid sibling'},
malformedMetadata,
],
});
expect(hub.items, hasLength(1));
expect(hub.items.single.id, 'valid-sibling');
expect(hub.items.single.title, 'Valid sibling');
final event = await capturedEvent.future;
_expectSafePlexMapperEvent(event, responseMarkers: responseMarkers, topLevelFieldCount: malformedMetadata.length);
});
});
test('PlexMetadataDto accepts string ratings', () {
final dto = PlexMetadataDto.fromJson({
'ratingKey': '1',
@@ -5,6 +5,7 @@ import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:plezy/database/app_database.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
@@ -14,6 +15,7 @@ import 'package:plezy/models/transcode_quality_preset.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/utils/active_client_scope.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/media_items.dart';
@@ -236,32 +238,36 @@ void main() {
expect(part.containsKey('Stream'), isFalse);
});
test('network failure falls back to lean cached playback metadata', () async {
await PlexApiCache.instance.put(ServerId('server-id'), '/library/metadata/42', {
'MediaContainer': {
'Metadata': [
{
'ratingKey': '42',
'type': 'movie',
'title': 'Movie',
'Media': [
{
'id': 7,
'Part': [
{'id': 10, 'key': '/library/parts/10/stale.mkv'},
],
},
{
'id': 8,
'Part': [
{'id': 20, 'key': '/library/parts/20/current.mkv'},
],
},
],
},
],
test('network failure falls back to profile-scoped lean cached playback metadata', () async {
await PlexApiCache.instance.put(
buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile').cacheServerId,
'/library/metadata/42',
{
'MediaContainer': {
'Metadata': [
{
'ratingKey': '42',
'type': 'movie',
'title': 'Movie',
'Media': [
{
'id': 7,
'Part': [
{'id': 10, 'key': '/library/parts/10/stale.mkv'},
],
},
{
'id': 8,
'Part': [
{'id': 20, 'key': '/library/parts/20/current.mkv'},
],
},
],
},
],
},
},
});
);
final requests = <http.Request>[];
final client = makeClient((request) async {
requests.add(request);
@@ -527,4 +533,319 @@ void main() {
expect(subtitles, isEmpty);
});
group('playback metadata failure contract', () {
Map<String, dynamic> playableBody() => {
'MediaContainer': {
'Metadata': [
{
'ratingKey': '42',
'type': 'movie',
'Media': [
{
'id': 7,
'Part': [
{'id': 10, 'key': '/library/parts/10/file.mkv'},
],
},
],
},
],
},
};
Map<String, dynamic> noPartBody() => {
'MediaContainer': {
'Metadata': [
{
'ratingKey': '42',
'type': 'movie',
'Media': [
{'id': 7, 'Part': []},
],
},
],
},
};
PlaybackInitializationOptions options() => PlaybackInitializationOptions(
metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
selectedMediaIndex: 0,
);
test('raw helper preserves 401 while initialization classifies authentication', () async {
final client = makeClient(
(_) async =>
http.Response(jsonEncode({'error': 'body-canary'}), 401, headers: {'content-type': 'application/json'}),
);
addTearDown(client.close);
await expectLater(
client.getVideoPlaybackData('42'),
throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', 401)),
);
await expectLater(
client.getPlaybackInitialization(options()),
throwsA(
isA<PlaybackException>()
.having((error) => error.reason, 'reason', PlaybackFailureReason.authenticationRequired)
.having((error) => error.message, 'message', isNot(contains('body-canary'))),
),
);
});
test('raw timeout survives and initialization classifies server unavailable', () async {
final client = makeClient(
(_) async => throw MediaServerHttpException(
type: MediaServerHttpErrorType.receiveTimeout,
message: 'timeout-canary',
requestUri: Uri.parse('https://private.invalid/library/metadata/42?secret=uri-canary'),
),
);
addTearDown(client.close);
await expectLater(
client.getVideoPlaybackData('42'),
throwsA(
isA<MediaServerHttpException>().having(
(error) => error.type,
'type',
MediaServerHttpErrorType.receiveTimeout,
),
),
);
try {
await client.getPlaybackInitialization(options());
fail('Timeout must throw');
} on PlaybackException catch (error) {
expect(error.reason, PlaybackFailureReason.serverUnavailable);
expect(error.message, isNot(contains('timeout-canary')));
expect(error.toString(), isNot(anyOf(contains('private.invalid'), contains('uri-canary'))));
}
});
test('successful malformed envelope, Media, and Part collections are invalid data', () async {
final malformedBodies = <Map<String, dynamic>>[
{'notMediaContainer': true},
{
'MediaContainer': {
'Metadata': [
{'Media': 'payload-canary'},
],
},
},
{
'MediaContainer': {
'Metadata': [
{
'Media': [
{'Part': 'payload-canary'},
],
},
],
},
},
];
for (final body in malformedBodies) {
final client = makeClient(
(_) async => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}),
);
addTearDown(client.close);
await expectLater(client.getVideoPlaybackData('42'), throwsA(isA<FormatException>()));
await expectLater(
client.getPlaybackInitialization(options()),
throwsA(
isA<PlaybackException>()
.having((error) => error.reason, 'reason', PlaybackFailureReason.invalidPlaybackData)
.having((error) => error.toString(), 'safe text', isNot(contains('payload-canary'))),
),
);
}
});
test('playback validation preserves singleton and mixed valid Media/Part shapes', () async {
final bodies = <Map<String, dynamic>>[
{
'MediaContainer': {
'Metadata': [
{
'Media': {
'id': 7,
'Part': {'id': 10, 'key': '/library/parts/10/singleton.mkv'},
},
},
],
},
},
{
'MediaContainer': {
'Metadata': [
{
'Media': [
'ignored',
{
'id': 7,
'Part': [
'ignored',
{'id': 10, 'key': '/library/parts/10/mixed.mkv'},
],
},
],
},
],
},
},
];
for (final body in bodies) {
final client = makeClient(
(_) async => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}),
);
addTearDown(client.close);
final data = await client.getVideoPlaybackData('42');
expect(data.hasValidVideoUrl, isTrue);
expect(data.videoUrl, contains('/library/parts/10/'));
}
});
test('invalid JSON and non-map top-level data classify as invalid playback data', () async {
final responses = [
http.Response('{', 200, headers: {'content-type': 'application/json'}),
http.Response(jsonEncode([]), 200, headers: {'content-type': 'application/json'}),
];
for (final response in responses) {
final client = makeClient((_) async => response);
addTearDown(client.close);
await expectLater(
client.getPlaybackInitialization(options()),
throwsA(
isA<PlaybackException>().having(
(error) => error.reason,
'reason',
PlaybackFailureReason.invalidPlaybackData,
),
),
);
}
});
test('valid metadata without a part remains noPlayableSource', () async {
final client = makeClient(
(_) async => http.Response(jsonEncode(noPartBody()), 200, headers: {'content-type': 'application/json'}),
);
addTearDown(client.close);
final raw = await client.getVideoPlaybackData('42');
expect(raw.hasValidVideoUrl, isFalse);
await expectLater(
client.getPlaybackInitialization(options()),
throwsA(
isA<PlaybackException>().having((error) => error.reason, 'reason', PlaybackFailureReason.noPlayableSource),
),
);
});
test('auth, server, malformed, and no-source failures expose distinct reasons and messages', () async {
Future<PlaybackException> capture(PlexClient client) async {
try {
await client.getPlaybackInitialization(options());
fail('Initialization must throw');
} on PlaybackException catch (error) {
return error;
}
}
final auth = makeClient((_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'}));
final server = makeClient((_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'}));
final malformed = makeClient(
(_) async => http.Response(
jsonEncode({'MediaContainer': 'invalid'}),
200,
headers: {'content-type': 'application/json'},
),
);
final noSource = makeClient(
(_) async => http.Response(jsonEncode(noPartBody()), 200, headers: {'content-type': 'application/json'}),
);
addTearDown(auth.close);
addTearDown(server.close);
addTearDown(malformed.close);
addTearDown(noSource.close);
final failures = [await capture(auth), await capture(server), await capture(malformed), await capture(noSource)];
expect(failures.map((failure) => failure.reason).toSet(), {
PlaybackFailureReason.authenticationRequired,
PlaybackFailureReason.serverUnavailable,
PlaybackFailureReason.invalidPlaybackData,
PlaybackFailureReason.noPlayableSource,
});
expect(failures.map((failure) => failure.message).toSet(), hasLength(4));
});
test('500, connection failure, and cancellation never become no-source', () async {
final cases = <(PlaybackFailureReason, Future<http.Response> Function(http.Request))>[
(
PlaybackFailureReason.serverUnavailable,
(_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'}),
),
(
PlaybackFailureReason.serverUnavailable,
(_) async =>
throw MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'unavailable'),
),
(PlaybackFailureReason.cancelled, (request) async => throw http.RequestAbortedException(request.url)),
];
for (final (reason, handler) in cases) {
final client = makeClient(handler);
addTearDown(client.close);
await expectLater(
client.getPlaybackInitialization(options()),
throwsA(isA<PlaybackException>().having((error) => error.reason, 'reason', reason)),
);
}
});
test('unclassified failures use the safe unknown reason and message', () async {
final client = makeClient((_) async => throw StateError('unknown-cause-canary'));
addTearDown(client.close);
await expectLater(
client.getPlaybackInitialization(options()),
throwsA(
isA<PlaybackException>()
.having((error) => error.reason, 'reason', PlaybackFailureReason.unknown)
.having((error) => error.message, 'safe message', isNot(contains('unknown-cause-canary'))),
),
);
});
test('status failure still serves a valid cached playable row', () async {
await PlexApiCache.instance.put(
buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile').cacheServerId,
'/library/metadata/42',
playableBody(),
);
final client = makeClient((_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'}));
addTearDown(client.close);
final data = await client.getVideoPlaybackData('42');
expect(data.hasValidVideoUrl, isTrue);
expect(data.videoUrl, contains('/library/parts/10/file.mkv'));
});
test('external URL and download resolution propagate typed request failures', () async {
final client = makeClient((_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'}));
addTearDown(client.close);
final item = testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id');
await expectLater(client.resolveExternalPlaybackUrl(item), throwsA(isA<MediaServerHttpException>()));
await expectLater(client.resolveDownload(item), throwsA(isA<MediaServerHttpException>()));
});
});
}
@@ -150,6 +150,7 @@ void main() {
expect(result.selectedMediaIndex, 1);
expect(result.videoUrl, 'http://plex:32400/library/parts/20/file.mkv?X-Plex-Token=tok');
expect(result.mediaInfo?.mediaSourceId, '102');
});
test('selects version by preferred signature when the id misses', () {
@@ -185,6 +186,7 @@ void main() {
);
expect(result.selectedMediaIndex, 1);
expect(result.mediaInfo?.mediaSourceId, '202');
});
test('keeps the requested index when id and signature both miss', () {
@@ -214,6 +216,7 @@ void main() {
);
expect(result.selectedMediaIndex, 1);
expect(result.mediaInfo?.mediaSourceId, '302');
});
test('signature-resolved version still falls back when unplayable', () {
+414 -457
View File
@@ -1,426 +1,258 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/settings_export_service.dart';
import '../test_helpers/prefs.dart';
// NOTE on coverage scope:
// `SettingsExportService.exportToFile` and `importFromFile` both call into
// platform plumbing (FilePicker, PackageInfo, path_provider, dart:io.File).
// Per the task brief we only round-trip through the *pure* helpers
// `buildExportMap` and `applyImportMap` against an in-memory
// SharedPreferencesWithCache. That covers the user-prefix re-scoping, the
// allow/deny filtering, and the typed value (de)serialization — which is
// where the format-stability risk lives.
void main() {
setUp(resetSharedPreferencesForTest);
TestWidgetsFlutterBinding.ensureInitialized();
// ============================================================
// buildExportMap — header fields
// ============================================================
late _FakeFilePicker picker;
group('buildExportMap header', () {
test('emits the documented format version, an ISO8601 timestamp, and the platform', () async {
setUp(() {
resetSharedPreferencesForTest();
SettingsExportService.debugBeforeImportWrite = null;
picker = _FakeFilePicker();
FilePicker.platform = picker;
PackageInfo.setMockInitialValues(
appName: 'Plezy',
packageName: 'com.example.plezy',
version: '1.2.3',
buildNumber: '4',
buildSignature: '',
);
});
tearDown(() {
SettingsExportService.debugBeforeImportWrite = null;
});
group('portable settings registry', () {
test('exports every supported storage type and strips only active-user library scope', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final out = SettingsExportService.buildExportMap(prefs);
await prefs.setBool('enable_hardware_decoding', true);
await prefs.setInt('seek_time_small', 42);
await prefs.setDouble('volume', 75.5);
await prefs.setString('preferred_video_codec', 'h264');
await prefs.setStringList('user_alice_library_order', const ['movies', 'shows']);
await prefs.setStringList('user_bob_library_order', const ['private']);
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice', appVersion: '1.2.3');
final exported = out['prefs'] as Map<String, dynamic>;
expect(out['formatVersion'], SettingsExportService.formatVersion);
expect(out['appVersion'], '');
expect(out['exportedAt'], isA<String>());
// Sanity: the timestamp parses as an ISO-8601 instant.
expect(() => DateTime.parse(out['exportedAt'] as String), returnsNormally);
expect(out['platform'], isA<String>());
expect(out['prefs'], isA<Map>());
});
test('honors the supplied appVersion', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final out = SettingsExportService.buildExportMap(prefs, appVersion: '1.2.3');
expect(out['appVersion'], '1.2.3');
});
});
// ============================================================
// buildExportMap — type encoding round-trip
// ============================================================
group('buildExportMap type encoding', () {
test('encodes bool / int / double / string / stringList with type markers', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setBool('flag_a', true);
await prefs.setInt('count_a', 42);
await prefs.setDouble('volume', 0.75);
await prefs.setString('name', 'plezy');
await prefs.setStringList('list_a', const ['x', 'y']);
final out = SettingsExportService.buildExportMap(prefs);
final p = out['prefs'] as Map<String, dynamic>;
expect(p['flag_a'], {'type': 'bool', 'value': true});
expect(p['count_a'], {'type': 'int', 'value': 42});
expect(p['volume'], {'type': 'double', 'value': 0.75});
expect(p['name'], {'type': 'string', 'value': 'plezy'});
expect(p['list_a'], {
expect(DateTime.tryParse(out['exportedAt'] as String), isNotNull);
expect(exported['enable_hardware_decoding'], {'type': 'bool', 'value': true});
expect(exported['seek_time_small'], {'type': 'int', 'value': 42});
expect(exported['volume'], {'type': 'double', 'value': 75.5});
expect(exported['preferred_video_codec'], {'type': 'string', 'value': 'h264'});
expect(exported['library_order'], {
'type': 'stringList',
'value': ['x', 'y'],
'value': ['movies', 'shows'],
});
});
});
// ============================================================
// buildExportMap — denylist filtering
// ============================================================
group('buildExportMap denylist', () {
test('drops exact-deny credential keys', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
// Sample of the credential bucket — should never leak.
await prefs.setString('plex_token', 'abc');
await prefs.setString('client_identifier', 'xyz');
await prefs.setString('current_user_uuid', 'user-1');
await prefs.setString('active_app_profile_id', 'profile-1');
await prefs.setString('user_profile', '{}');
await prefs.setString('credential_vault_key_v1', 'base64-key');
// Plus a good-faith key that should stay.
await prefs.setBool('keep_me', true);
final out = SettingsExportService.buildExportMap(prefs);
final p = out['prefs'] as Map<String, dynamic>;
expect(p, isNot(contains('plex_token')));
expect(p, isNot(contains('client_identifier')));
expect(p, isNot(contains('current_user_uuid')));
expect(p, isNot(contains('active_app_profile_id')));
expect(p, isNot(contains('user_profile')));
expect(p, isNot(contains('credential_vault_key_v1')));
expect(p, contains('keep_me'));
expect(jsonEncode(out), isNot(contains('private')));
});
test('drops prefix-deny keys', () async {
test('excludes device-local download roots while preserving portable download controls', () async {
const sourcePath = '/source-device/downloads';
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('custom_download_path', sourcePath);
await prefs.setString('custom_download_path_type', 'saf');
await prefs.setBool('download_on_wifi_only', false);
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
final exported = out['prefs'] as Map<String, dynamic>;
final encoded = jsonEncode(out);
expect(out['formatVersion'], 1);
expect(exported['download_on_wifi_only'], {'type': 'bool', 'value': false});
expect(exported, isNot(contains('custom_download_path')));
expect(exported, isNot(contains('custom_download_path_type')));
expect(encoded, isNot(contains(sourcePath)));
});
test('fails closed for unknown, credential, account, path, history, and runtime keys', () async {
const canaries = ['SEERR-BEARER-CANARY', 'ACCOUNT-ID-CANARY', 'DEVICE-PATH-CANARY', 'RUNTIME-TIME-CANARY'];
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('server_endpoint_srv1', 'http://x');
await prefs.setInt('episode_count_show42', 24);
await prefs.setInt('watched_threshold_srv1', 95);
await prefs.setString('trakt_access_token', 'secret');
await prefs.setString('plex_home_users_conn-1', '[{"title":"Kid"}]');
await prefs.setInt('profile_last_used_profile-1', 123);
// The trakt feature flag uses a different prefix and SHOULD survive.
await prefs.setBool('enable_trakt_scrobble', true);
final out = SettingsExportService.buildExportMap(prefs);
final p = out['prefs'] as Map<String, dynamic>;
expect(p, isNot(contains('server_endpoint_srv1')));
expect(p, isNot(contains('episode_count_show42')));
expect(p, isNot(contains('watched_threshold_srv1')));
expect(p, isNot(contains('trakt_access_token')));
expect(p, isNot(contains('plex_home_users_conn-1')));
expect(p, isNot(contains('profile_last_used_profile-1')));
expect(p, contains('enable_trakt_scrobble'));
});
test('drops MAL / AniList / SIMKL session keys but keeps their feature toggles', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
// Stripped tracker session tokens — these would carry access_token /
// refresh_token JSON if they leaked into the export.
await prefs.setString('mal_session', '{"access_token":"a","refresh_token":"r"}');
await prefs.setString('anilist_session', '{"access_token":"a"}');
await prefs.setString('simkl_session', '{"access_token":"a"}');
// Feature toggles use the `enable_` prefix and SHOULD survive.
await prefs.setBool('enable_mal_scrobble', true);
await prefs.setBool('enable_anilist_scrobble', true);
await prefs.setBool('enable_simkl_scrobble', true);
final out = SettingsExportService.buildExportMap(prefs);
final p = out['prefs'] as Map<String, dynamic>;
expect(p, isNot(contains('mal_session')));
expect(p, isNot(contains('anilist_session')));
expect(p, isNot(contains('simkl_session')));
expect(p, contains('enable_mal_scrobble'));
expect(p, contains('enable_anilist_scrobble'));
expect(p, contains('enable_simkl_scrobble'));
});
test('user-scoped tracker sessions are dropped after the user prefix is stripped', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
// TrackerAccountStore writes under user_{uuid}_{baseKey}. After the
// active-user prefix is stripped on export, the key falls under the
// tracker prefix denylist.
await prefs.setString('user_alice_mal_session', '{"access_token":"a"}');
await prefs.setString('user_alice_anilist_session', '{"access_token":"a"}');
await prefs.setString('user_alice_simkl_session', '{"access_token":"a"}');
await prefs.setString('user_alice_trakt_session', '{"access_token":"a"}');
await prefs.setString('user_alice_seerr_session', '{"cookie":"${canaries[0]}","account":"${canaries[1]}"}');
await prefs.setString('current_user_uuid', canaries[1]);
await prefs.setString('custom_download_path', canaries[2]);
await prefs.setString('custom_download_path_type', 'saf');
await prefs.setBool('crash_reporting', true);
await prefs.setString('custom_relay_url', 'https://${canaries[2]}.invalid');
await prefs.setString('update_last_check_time', canaries[3]);
await prefs.setString('watch_together_recent_rooms', canaries[1]);
await prefs.setString('future_runtime_key', 'unknown');
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
final p = out['prefs'] as Map<String, dynamic>;
final encoded = jsonEncode(out);
final exported = out['prefs'] as Map<String, dynamic>;
expect(p, isNot(contains('mal_session')));
expect(p, isNot(contains('anilist_session')));
expect(p, isNot(contains('simkl_session')));
expect(p, isNot(contains('trakt_session')));
expect(exported.keys, contains('enable_trakt_scrobble'));
expect(exported.keys, isNot(contains('seerr_session')));
expect(exported.keys, isNot(contains('current_user_uuid')));
expect(exported.keys, isNot(contains('custom_download_path')));
expect(exported.keys, isNot(contains('custom_download_path_type')));
expect(exported.keys, isNot(contains('crash_reporting')));
expect(exported.keys, isNot(contains('custom_relay_url')));
expect(exported.keys, isNot(contains('update_last_check_time')));
expect(exported.keys, isNot(contains('watch_together_recent_rooms')));
expect(exported.keys, isNot(contains('future_runtime_key')));
for (final canary in canaries) {
expect(encoded, isNot(contains(canary)));
}
});
test('drops the internal migration flag', () async {
test('does not export any user-scoped value without an active user', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setBool('buffer_size_migrated_to_auto', true);
final out = SettingsExportService.buildExportMap(prefs);
expect((out['prefs'] as Map), isNot(contains('buffer_size_migrated_to_auto')));
await prefs.setStringList('user_alice_library_order', const ['movies']);
await prefs.setBool('enable_hdr', true);
final exported = SettingsExportService.buildExportMap(prefs)['prefs'] as Map<String, dynamic>;
expect(exported, contains('enable_hdr'));
expect(exported, isNot(contains('library_order')));
});
test('never exports tvOS database recovery generations or payloads', () async {
const canary = 'PROTECTED-RECOVERY-PAYLOAD-CANARY';
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('tvos_db_recovery_manifest_v1', '{"state":"committed"}');
await prefs.setString('tvos_db_recovery_identity_v1', canary);
await prefs.setString('tvos_db_recovery_pending_v1', canary);
await prefs.setBool('enable_hdr', true);
final export = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
final encoded = jsonEncode(export);
final exported = export['prefs'] as Map<String, dynamic>;
expect(exported, contains('enable_hdr'));
expect(exported.keys.where((key) => key.startsWith('tvos_db_recovery_')), isEmpty);
expect(encoded, isNot(contains(canary)));
});
});
// ============================================================
// buildExportMap — user-prefix scoping
// ============================================================
group('buildExportMap user-scoping', () {
test('strips the active user prefix on export', () async {
group('transactional import', () {
test('validates version and structure before writing', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setStringList('user_alice_library_order', const ['a', 'b']);
await prefs.setBool('user_alice_hidden_libraries_does_not_exist', true);
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
final p = out['prefs'] as Map<String, dynamic>;
// Active user's keys land under their *base* names.
expect(p, contains('library_order'));
expect(p['library_order'], {
'type': 'stringList',
'value': ['a', 'b'],
});
// Anything else under user_ that *isn't* the active user is excluded —
// the synthetic key above lives under "alice" and so it goes through.
expect(p, contains('hidden_libraries_does_not_exist'));
});
test('skips other users\' scoped keys entirely', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setStringList('user_alice_library_order', const ['a']);
await prefs.setStringList('user_bob_library_order', const ['b']);
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
final p = out['prefs'] as Map<String, dynamic>;
// alice's value made it through (stripped to base key).
expect(p['library_order'], {
'type': 'stringList',
'value': ['a'],
});
// bob's was filtered out — there's no second pref with that name.
expect(p.values.where((v) => (v as Map)['value'] is List && (v['value'] as List).contains('b')), isEmpty);
});
test('without currentUserUuid: every user_-prefixed key is skipped', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setStringList('user_alice_library_order', const ['a']);
await prefs.setBool('global_flag', true);
final out = SettingsExportService.buildExportMap(prefs); // no UUID
final p = out['prefs'] as Map<String, dynamic>;
expect(p, contains('global_flag'));
// Every user_-scoped key is skipped because we have no active user.
expect(p.keys.where((k) => k.startsWith('user_')), isEmpty);
expect(p, isNot(contains('library_order')));
});
});
// ============================================================
// applyImportMap — version + structure validation
// ============================================================
group('applyImportMap validation', () {
test('throws when formatVersion is missing or wrong type', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
// Missing
expect(
() => SettingsExportService.applyImportMap({'prefs': const <String, dynamic>{}}, prefs, currentUserUuid: 'u'),
await expectLater(
SettingsExportService.applyImportMap({'prefs': const {}}, prefs, currentUserUuid: 'alice'),
throwsA(isA<InvalidExportFileException>()),
);
// Wrong type
expect(
() => SettingsExportService.applyImportMap(
{'formatVersion': 'one', 'prefs': const <String, dynamic>{}},
await expectLater(
SettingsExportService.applyImportMap(
{'formatVersion': SettingsExportService.formatVersion + 1, 'prefs': const {}},
prefs,
currentUserUuid: 'u',
currentUserUuid: 'alice',
),
throwsA(isA<InvalidExportFileException>()),
);
});
test('throws when formatVersion is newer than the supported one', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
expect(
() => SettingsExportService.applyImportMap(
{'formatVersion': SettingsExportService.formatVersion + 1, 'prefs': const <String, dynamic>{}},
await expectLater(
SettingsExportService.applyImportMap(
{'formatVersion': SettingsExportService.formatVersion, 'prefs': 'invalid'},
prefs,
currentUserUuid: 'u',
currentUserUuid: 'alice',
),
throwsA(isA<InvalidExportFileException>()),
);
expect(prefs.getBool('enable_hdr'), isNull);
});
test('throws when prefs is missing or not a map', () async {
test('imports allowlisted values, re-scopes library settings, and skips unsafe entries', () async {
const seerrCanary = 'SEERR-IMPORT-CANARY';
final prefs = await BaseSharedPreferencesService.sharedCache();
expect(
() => SettingsExportService.applyImportMap(
{'formatVersion': SettingsExportService.formatVersion},
prefs,
currentUserUuid: 'u',
),
throwsA(isA<InvalidExportFileException>()),
);
expect(
() => SettingsExportService.applyImportMap(
{'formatVersion': SettingsExportService.formatVersion, 'prefs': 'not-a-map'},
prefs,
currentUserUuid: 'u',
),
throwsA(isA<InvalidExportFileException>()),
);
});
});
await prefs.setString('update_last_check_time', 'local-valid-value');
await prefs.setString('custom_download_path', '/target/device/downloads');
await prefs.setString('custom_download_path_type', 'file');
await prefs.setBool('crash_reporting', false);
// ============================================================
// applyImportMap — typed writes
// ============================================================
group('applyImportMap typed writes', () {
test('writes bool / int / double / string / stringList back into prefs', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'a_flag': {'type': 'bool', 'value': true},
'a_int': {'type': 'int', 'value': 7},
'a_double': {'type': 'double', 'value': 1.5},
'a_string': {'type': 'string', 'value': 'hi'},
'a_list': {
'type': 'stringList',
'value': ['x', 'y'],
},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 5);
expect(result.keysSkipped, 0);
expect(prefs.getBool('a_flag'), isTrue);
expect(prefs.getInt('a_int'), 7);
expect(prefs.getDouble('a_double'), 1.5);
expect(prefs.getString('a_string'), 'hi');
expect(prefs.getStringList('a_list'), ['x', 'y']);
});
test('double accepts num input (importing an int as double)', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
// Value is encoded as int but typed as double — should still write.
'speed': {'type': 'double', 'value': 2},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 1);
expect(prefs.getDouble('speed'), 2.0);
});
test('skips entries with mismatched type/value pairs without throwing', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
// bool with non-bool value
'bad_bool': {'type': 'bool', 'value': 'yes'},
// unknown type tag
'bad_type': {'type': 'enum', 'value': 'foo'},
// not a map at all
'not_map': 'whatever',
// missing type key
'no_type': {'value': 1},
// type isn't a string
'type_not_str': {'type': 1, 'value': 1},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 0);
expect(result.keysSkipped, 5);
// None of the bad keys ended up in prefs.
expect(prefs.getBool('bad_bool'), isNull);
expect(prefs.getString('bad_type'), isNull);
expect(prefs.getString('not_map'), isNull);
});
test('skips deny-listed keys even if present in the import payload', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'plex_token': {'type': 'string', 'value': 'malicious'},
'credential_vault_key_v1': {'type': 'string', 'value': 'attacker-key'},
'active_app_profile_id': {'type': 'string', 'value': 'stale-profile'},
'server_endpoint_srv': {'type': 'string', 'value': 'http://attacker.test'},
'plex_home_users_conn': {'type': 'string', 'value': '[]'},
'profile_last_used_stale': {'type': 'int', 'value': 1},
'good_key': {'type': 'bool', 'value': true},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 1);
expect(result.keysSkipped, 6);
expect(prefs.getString('plex_token'), isNull);
expect(prefs.getString('credential_vault_key_v1'), isNull);
expect(prefs.getString('active_app_profile_id'), isNull);
expect(prefs.getString('server_endpoint_srv'), isNull);
expect(prefs.getString('plex_home_users_conn'), isNull);
expect(prefs.getInt('profile_last_used_stale'), isNull);
expect(prefs.getBool('good_key'), isTrue);
});
});
// ============================================================
// applyImportMap — user-scoped re-scoping
// ============================================================
group('applyImportMap user-scoping', () {
test('re-applies the active user prefix to scoped base keys', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
// exact-match scoped base keys
'enable_hardware_decoding': {'type': 'bool', 'value': true},
'default_playback_speed': {'type': 'double', 'value': 1},
'library_order': {
'type': 'stringList',
'value': ['a', 'b'],
'value': ['movies'],
},
'hidden_libraries': {'type': 'string', 'value': '["lib1"]'},
// prefix-match scoped base keys
'library_filters_section1': {'type': 'string', 'value': '{}'},
'library_sort_section1': {'type': 'string', 'value': 'titleSort'},
'library_grouping_section1': {'type': 'string', 'value': 'shows'},
'library_tab_section1': {'type': 'string', 'value': 'recommended'},
// global key — must NOT be scoped
'library_sort_movies': {'type': 'string', 'value': '{"key":"titleSort"}'},
'seerr_session': {'type': 'string', 'value': seerrCanary},
'update_last_check_time': {'type': 'string', 'value': 'crafted-invalid'},
'custom_download_path': {'type': 'string', 'value': '/source/device/downloads'},
'custom_download_path_type': {'type': 'string', 'value': 'saf'},
'crash_reporting': {'type': 'bool', 'value': true},
'unknown_future_key': {'type': 'bool', 'value': true},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 4);
expect(result.keysSkipped, 6);
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
expect(prefs.getDouble('default_playback_speed'), 1.0);
expect(prefs.getStringList('user_alice_library_order'), ['movies']);
expect(prefs.getString('user_alice_library_sort_movies'), '{"key":"titleSort"}');
expect(prefs.getString('seerr_session'), isNull);
expect(prefs.getString('user_alice_seerr_session'), isNull);
expect(prefs.getString('update_last_check_time'), 'local-valid-value');
expect(prefs.getString('custom_download_path'), '/target/device/downloads');
expect(prefs.getString('custom_download_path_type'), 'file');
expect(prefs.getBool('crash_reporting'), isFalse);
expect(prefs.getBool('unknown_future_key'), isNull);
});
test('skips source download roots and preserves the target device root', () async {
const targetPath = '/target-device/downloads';
const sourcePath = '/source-device/downloads';
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('custom_download_path', targetPath);
await prefs.setString('custom_download_path_type', 'file');
await prefs.setBool('download_on_wifi_only', true);
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': 1,
'prefs': {
'custom_download_path': {'type': 'string', 'value': sourcePath},
'custom_download_path_type': {'type': 'string', 'value': 'saf'},
'download_on_wifi_only': {'type': 'bool', 'value': false},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 1);
expect(result.keysSkipped, 2);
expect(prefs.getBool('download_on_wifi_only'), isFalse);
expect(prefs.getString('custom_download_path'), targetPath);
expect(prefs.getString('custom_download_path_type'), 'file');
expect(prefs.getString('custom_download_path'), isNot(contains(sourcePath)));
});
test('skips malformed or mismatched entries before applying valid mutations', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'enable_hdr': {'type': 'bool', 'value': 'yes'},
'seek_time_small': {'type': 'string', 'value': '10'},
'volume': {'value': 50},
'preferred_video_codec': 'not-an-entry',
'enable_hardware_decoding': {'type': 'bool', 'value': true},
},
},
@@ -428,109 +260,234 @@ void main() {
currentUserUuid: 'alice',
);
expect(result.keysImported, 7);
// Scoped keys land under user_alice_*
expect(prefs.getStringList('user_alice_library_order'), ['a', 'b']);
expect(prefs.getString('user_alice_hidden_libraries'), '["lib1"]');
expect(prefs.getString('user_alice_library_filters_section1'), '{}');
expect(prefs.getString('user_alice_library_sort_section1'), 'titleSort');
expect(prefs.getString('user_alice_library_grouping_section1'), 'shows');
expect(prefs.getString('user_alice_library_tab_section1'), 'recommended');
// Global key stays unscoped.
expect(result.keysImported, 1);
expect(result.keysSkipped, 4);
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
expect(prefs.getBool('user_alice_enable_hardware_decoding'), isNull);
});
});
// ============================================================
// Round-trip
// ============================================================
group('round-trip', () {
test('build → JSON → parse → apply produces the same key/value/type', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
// Seed a representative mix.
await prefs.setBool('enable_hardware_decoding', true);
await prefs.setInt('seek_time_small', 15);
await prefs.setDouble('volume', 0.75);
await prefs.setString('preferred_video_codec', 'h264');
await prefs.setStringList('shader_list', const ['a', 'b', 'c']);
// User-scoped data for "alice".
await prefs.setStringList('user_alice_library_order', const ['lib-1', 'lib-2']);
// Credential we expect to be stripped.
await prefs.setString('plex_token', 'never-this');
// Export.
final exportMap = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice', appVersion: '9.9.9');
final encoded = json.encode(exportMap);
// Wipe prefs to simulate a fresh device.
await prefs.clear();
// Confirm wipe.
expect(prefs.getBool('enable_hdr'), isNull);
expect(prefs.getInt('seek_time_small'), isNull);
expect(prefs.getStringList('user_alice_library_order'), isNull);
// Parse back and import — same alice, so scoped keys round-trip cleanly.
final decoded = json.decode(encoded) as Map<String, dynamic>;
final result = await SettingsExportService.applyImportMap(decoded, prefs, currentUserUuid: 'alice');
// 6 expected keys round-trip; the count includes the unrelated
// `plezy_legacy_prefs_migrated_v1` flag the cache plants. We only assert
// it is at LEAST our expected six keys, not an exact count.
expect(result.keysImported, greaterThanOrEqualTo(6));
expect(result.keysSkipped, 0);
// Values restored under their original keys (with re-applied scoping).
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
expect(prefs.getInt('seek_time_small'), 15);
expect(prefs.getDouble('volume'), 0.75);
expect(prefs.getString('preferred_video_codec'), 'h264');
expect(prefs.getStringList('shader_list'), ['a', 'b', 'c']);
expect(prefs.getStringList('user_alice_library_order'), ['lib-1', 'lib-2']);
// Credential never came back.
expect(prefs.getString('plex_token'), isNull);
});
test('cross-user round-trip: alice exports → bob imports → keys land under bob', () async {
test('rolls every mutation back when a later preference write fails', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setStringList('user_alice_library_order', const ['lib-a', 'lib-b']);
await prefs.setBool('enable_hdr', false);
var writes = 0;
SettingsExportService.debugBeforeImportWrite = (_) {
writes++;
if (writes == 2) throw StateError('synthetic write failure');
};
final exportMap = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
// Wipe alice's data.
await expectLater(
SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'enable_hdr': {'type': 'bool', 'value': true},
'seek_time_small': {'type': 'int', 'value': 15},
},
},
prefs,
currentUserUuid: 'alice',
),
throwsA(isA<SettingsExportException>()),
);
expect(prefs.getBool('enable_hdr'), isFalse);
expect(prefs.getInt('seek_time_small'), isNull);
});
test('round-trips portable values across user scopes without account identifiers', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setBool('enable_hardware_decoding', true);
await prefs.setStringList('user_alice_library_order', const ['movies']);
final export = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
expect(jsonEncode(export), isNot(contains('alice')));
await prefs.clear();
// Bob imports. Scoped base key gets re-applied with bob's prefix.
final result = await SettingsExportService.applyImportMap(exportMap, prefs, currentUserUuid: 'bob');
// The cache plants `plezy_legacy_prefs_migrated_v1` on first init, so
// the export count includes that flag too. Just confirm the scoped
// value made it through.
expect(result.keysImported, greaterThanOrEqualTo(1));
final result = await SettingsExportService.applyImportMap(export, prefs, currentUserUuid: 'bob');
// Alice's data is now under bob's namespace.
expect(prefs.getStringList('user_bob_library_order'), ['lib-a', 'lib-b']);
expect(result.keysImported, 2);
expect(result.keysSkipped, 0);
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
expect(prefs.getStringList('user_bob_library_order'), ['movies']);
expect(prefs.getStringList('user_alice_library_order'), isNull);
});
test('malicious import cannot replace any tvOS database recovery key', () async {
const originalManifest = 'LOCAL-MANIFEST';
const originalIdentity = 'LOCAL-IDENTITY';
const originalPending = 'LOCAL-PENDING';
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('tvos_db_recovery_manifest_v1', originalManifest);
await prefs.setString('tvos_db_recovery_identity_v1', originalIdentity);
await prefs.setString('tvos_db_recovery_pending_v1', originalPending);
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'tvos_db_recovery_manifest_v1': {'type': 'string', 'value': 'MALICIOUS-MANIFEST'},
'tvos_db_recovery_identity_v1': {'type': 'string', 'value': 'MALICIOUS-IDENTITY'},
'tvos_db_recovery_pending_v1': {'type': 'string', 'value': 'MALICIOUS-PENDING'},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 0);
expect(result.keysSkipped, 3);
expect(prefs.getString('tvos_db_recovery_manifest_v1'), originalManifest);
expect(prefs.getString('tvos_db_recovery_identity_v1'), originalIdentity);
expect(prefs.getString('tvos_db_recovery_pending_v1'), originalPending);
});
});
// ============================================================
// Exception types
// ============================================================
group('file orchestration', () {
Future<void> seedActiveProfile() async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('active_app_profile_id', 'profile-a');
await prefs.setBool('enable_hdr', true);
}
group('exception types', () {
test('NoUserSignedInException is a SettingsExportException', () {
const ex = NoUserSignedInException();
expect(ex, isA<SettingsExportException>());
expect(ex.toString(), contains('No user is signed in'));
Uint8List importBytes({bool enableHdr = false}) {
return Uint8List.fromList(
utf8.encode(
jsonEncode({
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'enable_hdr': {'type': 'bool', 'value': enableHdr},
},
}),
),
);
}
test('exports captured JSON bytes with package version and requested file contract', () async {
await seedActiveProfile();
picker.saveResult = '/tmp/plezy-settings.json';
final path = await SettingsExportService.exportToFile();
expect(path, '/tmp/plezy-settings.json');
expect(picker.lastSaveName, matches(RegExp(r'^plezy-settings-\d{8}\.json$')));
expect(picker.lastSaveExtensions, ['json']);
final decoded = jsonDecode(utf8.decode(picker.lastSaveBytes!)) as Map<String, dynamic>;
expect(decoded['appVersion'], '1.2.3');
expect((decoded['prefs'] as Map)['enable_hdr'], {'type': 'bool', 'value': true});
});
test('InvalidExportFileException is a SettingsExportException with message', () {
const ex = InvalidExportFileException('bad shape');
expect(ex, isA<SettingsExportException>());
expect(ex.toString(), contains('bad shape'));
test('save cancellation and failure release the picker guard for a later operation', () async {
await seedActiveProfile();
picker.saveResult = null;
expect(await SettingsExportService.exportToFile(), isNull);
picker.saveError = PlatformException(code: 'save_failed');
await expectLater(SettingsExportService.exportToFile(), throwsA(isA<SettingsExportException>()));
picker.saveError = null;
picker.saveResult = '/tmp/recovered.json';
expect(await SettingsExportService.exportToFile(), '/tmp/recovered.json');
expect(picker.saveCalls, 3);
});
test('imports in-memory bytes and path-backed files', () async {
await seedActiveProfile();
final prefs = await BaseSharedPreferencesService.sharedCache();
picker.pickResult = FilePickerResult([PlatformFile(name: 'settings.json', size: 1, bytes: importBytes())]);
final memoryResult = await SettingsExportService.importFromFile();
expect(memoryResult?.keysImported, 1);
expect(prefs.getBool('enable_hdr'), isFalse);
final directory = await Directory.systemTemp.createTemp('plezy-settings-import-');
addTearDown(() => directory.delete(recursive: true));
final file = File('${directory.path}/settings.json');
await file.writeAsBytes(importBytes(enableHdr: true));
picker.pickResult = FilePickerResult([
PlatformFile(name: 'settings.json', size: await file.length(), path: file.path),
]);
final pathResult = await SettingsExportService.importFromFile();
expect(pathResult?.keysImported, 1);
expect(prefs.getBool('enable_hdr'), isTrue);
});
test('picker cancellation, malformed input, and unreadable path release the guard', () async {
await seedActiveProfile();
picker.pickResult = null;
expect(await SettingsExportService.importFromFile(), isNull);
picker.pickResult = FilePickerResult([
PlatformFile(name: 'bad.json', size: 1, bytes: Uint8List.fromList(utf8.encode('{bad'))),
]);
await expectLater(SettingsExportService.importFromFile(), throwsA(isA<InvalidExportFileException>()));
picker.pickResult = FilePickerResult([
PlatformFile(name: 'missing.json', size: 1, path: '/path/that/does/not/exist.json'),
]);
await expectLater(SettingsExportService.importFromFile(), throwsA(isA<InvalidExportFileException>()));
picker.pickResult = FilePickerResult([PlatformFile(name: 'settings.json', size: 1, bytes: importBytes())]);
expect((await SettingsExportService.importFromFile())?.keysImported, 1);
expect(picker.pickCalls, 4);
});
test('missing active profile rejects before opening the picker', () async {
await expectLater(SettingsExportService.importFromFile(), throwsA(isA<NoUserSignedInException>()));
expect(picker.pickCalls, 0);
});
});
}
class _FakeFilePicker extends FilePicker {
FilePickerResult? pickResult;
String? saveResult;
Object? pickError;
Object? saveError;
int pickCalls = 0;
int saveCalls = 0;
String? lastSaveName;
List<String>? lastSaveExtensions;
Uint8List? lastSaveBytes;
@override
Future<FilePickerResult?> pickFiles({
String? dialogTitle,
String? initialDirectory,
FileType type = FileType.any,
List<String>? allowedExtensions,
Function(FilePickerStatus)? onFileLoading,
bool allowCompression = false,
int compressionQuality = 0,
bool allowMultiple = false,
bool withData = false,
bool withReadStream = false,
bool lockParentWindow = false,
bool readSequential = false,
}) async {
pickCalls++;
final error = pickError;
if (error != null) throw error;
return pickResult;
}
@override
Future<String?> saveFile({
String? dialogTitle,
String? fileName,
String? initialDirectory,
FileType type = FileType.any,
List<String>? allowedExtensions,
Uint8List? bytes,
bool lockParentWindow = false,
}) async {
saveCalls++;
lastSaveName = fileName;
lastSaveExtensions = allowedExtensions;
lastSaveBytes = bytes;
final error = saveError;
if (error != null) throw error;
return saveResult;
}
}
+101 -56
View File
@@ -1,23 +1,10 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fake_async/fake_async.dart';
import 'package:plezy/services/sleep_timer_service.dart';
// IMPORTANT: [SleepTimerService] uses raw `DateTime.now()` (not
// `clock.now()` from package:clock), so `fake_async` cannot virtualize the
// service's wall-clock arithmetic. Specifically, `remainingTime` computes
// `endTime.difference(DateTime.now())` against the real system clock, while
// the periodic Timer ticks every 1s in fake time but always sees a near-zero
// elapsed wall clock — so the prompt never fires under `fakeAsync`.
//
// Strategy:
// - State assertions (start/cancel/extend/restart bookkeeping) use the real
// clock with sub-second resolution.
// - We do NOT exercise the prompt-fires-when-elapsed branch because the
// periodic tick is hard-coded at 1s and waiting that long in tests is
// flaky. That branch is documented as uncovered at the bottom of this file.
//
// The service is a process-global singleton, so each test calls `cancelTimer`
// in setUp/tearDown to reset bookkeeping. We never call `dispose()` (it would
// close shared StreamControllers and break subsequent tests).
// Duration-based transitions use an injected clock with fake_async so timer
// ticks and wall-clock arithmetic advance together. The production singleton
// remains covered separately for its shared-instance contract.
void main() {
late SleepTimerService timer;
@@ -68,16 +55,14 @@ void main() {
}
});
test('endTime is approximately now + duration (real clock)', () {
final before = DateTime.now();
timer.startTimer(const Duration(minutes: 10), () {});
try {
final delta = timer.endTime!.difference(before).inSeconds;
// Generous bounds for any millisecond-scale slop between sample points.
expect(delta, inInclusiveRange(599, 601));
} finally {
timer.cancelTimer();
}
test('endTime is based on the injected clock', () {
final now = DateTime.utc(2026, 7, 20, 12);
final service = SleepTimerService.withClock(() => now);
service.startTimer(const Duration(minutes: 10), () {});
expect(service.endTime, now.add(const Duration(minutes: 10)));
service.dispose();
});
test('starting a new timer cancels the previous one', () {
@@ -101,21 +86,27 @@ void main() {
// ============================================================
group('cancelTimer', () {
test('clears all state and stops the periodic ticker', () async {
var fired = false;
timer.startTimer(const Duration(minutes: 5), () => fired = true);
test('clears all state and prevents a later prompt', () {
fakeAsync((async) {
final epoch = DateTime.utc(2026, 7, 20, 12);
final service = SleepTimerService.withClock(() => epoch.add(async.elapsed));
var prompts = 0;
service.onPrompt.listen((_) => prompts++);
service.startTimer(const Duration(seconds: 2), () {});
timer.cancelTimer();
expect(timer.isActive, isFalse);
expect(timer.endTime, isNull);
expect(timer.duration, isNull);
expect(timer.originalDuration, isNull);
async.elapse(const Duration(seconds: 1));
service.cancelTimer();
async.elapse(const Duration(minutes: 1));
async.flushMicrotasks();
// Pump the event queue briefly to confirm the periodic Timer is dead —
// even in real time we can be sure the user callback never fires for a
// 5-minute timer that we cancel immediately.
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(fired, isFalse);
expect(service.isActive, isFalse);
expect(service.endTime, isNull);
expect(service.duration, isNull);
expect(service.originalDuration, isNull);
expect(prompts, 0);
service.dispose();
async.flushMicrotasks();
});
});
test('cancelTimer on idle service is a no-op', () {
@@ -124,6 +115,76 @@ void main() {
});
});
group('duration transitions', () {
test('remaining time elapses and emits one prompt on the first due tick', () {
fakeAsync((async) {
final epoch = DateTime.utc(2026, 7, 20, 12);
final service = SleepTimerService.withClock(() => epoch.add(async.elapsed));
var prompts = 0;
var completions = 0;
service.onPrompt.listen((_) => prompts++);
service.startTimer(const Duration(seconds: 3), () => completions++);
expect(service.remainingTime, const Duration(seconds: 3));
async.elapse(const Duration(seconds: 2));
async.flushMicrotasks();
expect(service.remainingTime, const Duration(seconds: 1));
expect(prompts, 0);
async.elapse(const Duration(milliseconds: 999));
async.flushMicrotasks();
expect(service.remainingTime, const Duration(milliseconds: 1));
expect(prompts, 0);
async.elapse(const Duration(milliseconds: 1));
async.flushMicrotasks();
expect(prompts, 1);
expect(completions, 0);
expect(service.isActive, isFalse);
expect(service.remainingTime, isNull);
async.elapse(const Duration(minutes: 1));
async.flushMicrotasks();
expect(prompts, 1);
service.dispose();
async.flushMicrotasks();
});
});
test('restartTimer after a prompt restarts the original duration and callback', () {
fakeAsync((async) {
final epoch = DateTime.utc(2026, 7, 20, 12);
final service = SleepTimerService.withClock(() => epoch.add(async.elapsed));
var prompts = 0;
var completions = 0;
service.onPrompt.listen((_) => prompts++);
service.startTimer(const Duration(seconds: 2), () => completions++);
async.elapse(const Duration(seconds: 2));
async.flushMicrotasks();
expect(prompts, 1);
expect(service.isActive, isFalse);
service.restartTimer();
expect(service.isActive, isTrue);
expect(service.endTime, epoch.add(const Duration(seconds: 4)));
expect(service.remainingTime, const Duration(seconds: 2));
async.elapse(const Duration(seconds: 2));
async.flushMicrotasks();
expect(prompts, 2);
expect(completions, 0);
service.executeCompletion();
async.flushMicrotasks();
expect(completions, 1);
service.dispose();
async.flushMicrotasks();
});
});
});
// ============================================================
// restartTimer / restartIfNeeded / markNeedsRestart
// ============================================================
@@ -400,20 +461,4 @@ void main() {
timer.cancelTimer();
});
});
// ============================================================
// What's NOT covered (and why)
// ============================================================
//
// - The prompt-fires-when-duration-elapses branch in `startTimer`:
// The periodic Timer fires every 1s, and the production code uses raw
// `DateTime.now()` for end/elapsed math, so neither `fake_async` nor
// `package:clock` substitutes can virtualize it without touching the
// service. Verifying it would require a wall-clock wait of >1s, which
// is flaky for unit tests.
//
// - `restartTimer` after `_stopTimerOnly` (the post-prompt path):
// `_stopTimerOnly` is private and only reached by the periodic-tick
// completion above, so the post-prompt restart flow is also not
// verifiable here without injecting a clock dependency.
}
@@ -0,0 +1,170 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/services/system_shelf_service.dart';
import '../test_helpers/media_items.dart';
class _ShelfClient implements MediaServerClient {
_ShelfClient({this.throwOnThumbnail = false});
final bool throwOnThumbnail;
@override
ServerId get serverId => ServerId('server-a');
@override
String get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.plex;
@override
ServerCapabilities get capabilities => ServerCapabilities.plex;
@override
String thumbnailUrl(String? path, {int? width, int? height}) {
if (throwOnThumbnail) throw StateError('conversion failed');
expect(width, 640);
expect(height, 360);
return 'https://media.invalid/poster.jpg?token=transient';
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('test/system_shelf');
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
tearDown(() {
messenger.setMockMethodCallHandler(channel, null);
});
test('delayed support result is dropped after synchronous owner invalidation', () async {
final support = Completer<bool>();
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return true;
});
final service = SystemShelfService.forTesting(channel: channel, isSupported: () => support.future);
service.beginProfileSession('owner-a');
final delayed = service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient());
final ended = service.endProfileSession('owner-a');
support.complete(true);
expect(await delayed, isFalse);
await ended;
expect(calls.map((call) => call.method), ['clear']);
expect(calls.single.arguments, {
'schemaVersion': SystemShelfService.schemaVersion,
'ownerId': 'owner-a',
'generation': 2,
});
expect(await service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()), isFalse);
});
test('dispatched old sync settles before clear and new owner sync', () async {
final syncDispatched = Completer<void>();
final releaseOldSync = Completer<void>();
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
if (call.method == 'sync' && !syncDispatched.isCompleted) {
syncDispatched.complete();
await releaseOldSync.future;
}
return true;
});
final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
service.beginProfileSession('owner-a');
final oldSync = service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient());
await syncDispatched.future;
final oldEnd = service.endProfileSession('owner-a');
service.beginProfileSession('owner-b');
final newSync = service.syncFromContinueWatching('owner-b', const [], (_) => _ShelfClient());
await Future<void>.delayed(Duration.zero);
expect(calls.map((call) => call.method), ['sync']);
releaseOldSync.complete();
expect(await oldSync, isTrue);
await oldEnd;
expect(await newSync, isTrue);
expect(calls.map((call) => call.method), ['sync', 'clear', 'sync']);
expect((calls.last.arguments as Map)['ownerId'], 'owner-b');
});
test('native failure is contained and the ordered tail accepts a later sync', () async {
var syncCount = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'sync' && syncCount++ == 0) {
throw PlatformException(code: 'first-failed');
}
return true;
});
final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
service.beginProfileSession('owner-a');
expect(await service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()), isFalse);
expect(await service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()), isTrue);
expect(syncCount, 2);
});
test('versioned payload carries transient source only and conversion failure keeps metadata', () async {
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return true;
});
final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
service.beginProfileSession('owner-a');
final item = testMediaItem(
id: 'item-a',
backend: MediaBackend.plex,
title: 'Private title',
summary: 'Private summary',
thumbPath: '/poster',
serverId: 'server-a',
serverName: 'Server',
);
expect(await service.syncFromContinueWatching('owner-a', [item], (_) => _ShelfClient()), isTrue);
final envelope = calls.single.arguments as Map;
expect(envelope['schemaVersion'], SystemShelfService.schemaVersion);
expect(envelope['ownerId'], 'owner-a');
final sent = (envelope['items'] as List).single as Map;
expect(sent['posterSourceUri'], startsWith('https://media.invalid/'));
expect(sent, isNot(contains('posterUri')));
calls.clear();
expect(
await service.syncFromContinueWatching('owner-a', [item], (_) => _ShelfClient(throwOnThumbnail: true)),
isTrue,
);
final fallback = (((calls.single.arguments as Map)['items'] as List).single as Map);
expect(fallback['title'], 'Private title');
expect(fallback['posterSourceUri'], isNull);
});
test('unsupported integration completes without native mutation', () async {
var nativeCalls = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
nativeCalls++;
return true;
});
final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => false);
service.beginProfileSession('owner-a');
expect(await service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()), isFalse);
expect(nativeCalls, 0);
});
}
+255 -5
View File
@@ -27,6 +27,8 @@ import '../test_helpers/media_items.dart';
// fewer than 2 real tracks (early-return paths).
// - `applyTrackSelectionWhenReady` waits for subtitle tracks when server
// metadata says they exist.
// - `applyTrackSelection` awaits one audio/subtitle application on its
// captured player and reports failure or stale-owner cancellation.
// - `dispose` is idempotent (timers/subscriptions cleared).
//
// What's NOT covered:
@@ -62,8 +64,10 @@ class _FakePlayer with PlayerStreamControllersMixin implements Player {
@override
final bool attachesExternalSubtitlesAtOpen;
bool isDisposed = false;
@override
bool get disposed => false;
bool get disposed => isDisposed;
set tracks(Tracks t) {
_state = _state.copyWith(tracks: t);
@@ -78,10 +82,28 @@ class _FakePlayer with PlayerStreamControllersMixin implements Player {
final List<({String uri, String? title, String? language, bool select})> addSubtitleCalls = [];
final List<AudioTrack> selectedAudio = [];
final List<SubtitleTrack> selectedSubtitle = [];
final List<double> rates = [];
final List<Media> openedMedia = [];
/// If non-null and >0, fail this many addSubtitleTrack calls before succeeding.
int failAddSubtitleTimes = 0;
Future<void> Function(String uri)? onAddSubtitleTrack;
Object? selectAudioError;
Object? selectSubtitleError;
Future<void> Function(AudioTrack track)? onSelectAudioTrack;
Future<void> Function(SubtitleTrack track)? onSelectSubtitleTrack;
@override
Future<void> open(
Media media, {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? externalSubtitles,
Duration? timelineDuration,
}) async {
openedMedia.add(media);
}
@override
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
@@ -94,10 +116,23 @@ class _FakePlayer with PlayerStreamControllersMixin implements Player {
}
@override
Future<void> selectAudioTrack(AudioTrack t) async => selectedAudio.add(t);
Future<void> selectAudioTrack(AudioTrack t) async {
selectedAudio.add(t);
await onSelectAudioTrack?.call(t);
if (selectAudioError case final error?) throw error;
}
@override
Future<void> selectSubtitleTrack(SubtitleTrack t) async => selectedSubtitle.add(t);
Future<void> selectSubtitleTrack(SubtitleTrack t) async {
selectedSubtitle.add(t);
await onSelectSubtitleTrack?.call(t);
if (selectSubtitleError case final error?) throw error;
}
@override
Future<void> setRate(double rate) async {
rates.add(rate);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
@@ -108,17 +143,23 @@ TrackManager _make({
MediaItem? metadata,
MediaSourceInfo? mediaInfo,
bool active = true,
bool Function()? isActive,
Future<void> Function()? waitForProfileSettings,
AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack,
void Function(String, {Duration? duration})? showMessage,
TrackPreferencePersister? persister,
}) {
return TrackManager(
player: player,
isActive: () => active,
isActive: isActive ?? () => active,
persistTrackPreference: persister ?? _noopPersister,
getProfileSettings: () => null,
waitForProfileSettings: () async {},
waitForProfileSettings: waitForProfileSettings ?? () async {},
metadata: metadata ?? _meta(),
mediaInfo: mediaInfo,
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
showMessage: showMessage,
);
}
@@ -330,6 +371,215 @@ void main() {
});
});
group('applyTrackSelection ownership', () {
const audioTracks = [AudioTrack(id: 'audio-en', language: 'eng'), AudioTrack(id: 'audio-ja', language: 'jpn')];
const subtitleTracks = [SubtitleTrack(id: 'sub-en', language: 'eng'), SubtitleTrack(id: 'sub-es', language: 'spa')];
const availableTracks = Tracks(audio: audioTracks, subtitle: subtitleTracks);
test('awaits preferred audio and subtitle exactly once on the intended player', () async {
await SettingsService.getInstance();
final intendedPlayer = _FakePlayer(tracks: availableTracks);
final otherPlayer = _FakePlayer(tracks: availableTracks);
final mgr = _make(
player: intendedPlayer,
preferredAudioTrack: audioTracks[1],
preferredSubtitleTrack: subtitleTracks[1],
);
addTearDown(mgr.dispose);
final applied = await mgr.applyTrackSelection();
expect(applied, isTrue);
expect(intendedPlayer.selectedAudio.map((track) => track.id), ['audio-ja']);
expect(intendedPlayer.selectedSubtitle.map((track) => track.id), ['sub-es']);
expect(otherPlayer.selectedAudio, isEmpty);
expect(otherPlayer.selectedSubtitle, isEmpty);
});
test('reports player selection failure and does not continue to subtitles', () async {
await SettingsService.getInstance();
final player = _FakePlayer(tracks: availableTracks)..selectAudioError = StateError('audio selection failed');
final mgr = _make(player: player, preferredAudioTrack: audioTracks[1], preferredSubtitleTrack: subtitleTracks[1]);
addTearDown(mgr.dispose);
final applied = await mgr.applyTrackSelection();
expect(applied, isFalse);
expect(player.selectedAudio.map((track) => track.id), ['audio-ja']);
expect(player.selectedSubtitle, isEmpty);
});
test('cancels between selections when ownership moves to another player', () async {
await SettingsService.getInstance();
final audioSelectionStarted = Completer<void>();
final releaseAudioSelection = Completer<void>();
final intendedPlayer = _FakePlayer(tracks: availableTracks)
..onSelectAudioTrack = (_) async {
audioSelectionStarted.complete();
await releaseAudioSelection.future;
};
final replacementPlayer = _FakePlayer(tracks: availableTracks);
Player activePlayer = intendedPlayer;
final mgr = _make(
player: intendedPlayer,
isActive: () => identical(activePlayer, intendedPlayer),
preferredAudioTrack: audioTracks[1],
preferredSubtitleTrack: subtitleTracks[1],
);
addTearDown(mgr.dispose);
final application = mgr.applyTrackSelection();
await audioSelectionStarted.future;
activePlayer = replacementPlayer;
releaseAudioSelection.complete();
expect(await application, isFalse);
expect(intendedPlayer.selectedAudio.map((track) => track.id), ['audio-ja']);
expect(intendedPlayer.selectedSubtitle, isEmpty);
expect(replacementPlayer.selectedAudio, isEmpty);
expect(replacementPlayer.selectedSubtitle, isEmpty);
});
test('media generation invalidation ignores a late completion before any player mutation', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.defaultPlaybackSpeed, 1.5);
final profileWaitStarted = Completer<void>();
final releaseProfileWait = Completer<void>();
final player = _FakePlayer(tracks: availableTracks);
final mgr = _make(
player: player,
waitForProfileSettings: () async {
profileWaitStarted.complete();
await releaseProfileWait.future;
},
preferredAudioTrack: audioTracks[1],
preferredSubtitleTrack: subtitleTracks[1],
);
addTearDown(mgr.dispose);
final application = mgr.applyTrackSelection();
await profileWaitStarted.future;
await mgr.invalidatePendingSelection();
releaseProfileWait.complete();
expect(await application, isFalse);
expect(player.selectedAudio, isEmpty);
expect(player.selectedSubtitle, isEmpty);
expect(player.rates, isEmpty);
});
test('replacement generation selection waits for stale selection unwind', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.defaultPlaybackSpeed, 1.5);
final staleProfileWaitStarted = Completer<void>();
final releaseStaleProfileWait = Completer<void>();
var profileWaitCount = 0;
final player = _FakePlayer(tracks: availableTracks);
final mgr = _make(
player: player,
waitForProfileSettings: () {
profileWaitCount++;
if (profileWaitCount == 1) {
staleProfileWaitStarted.complete();
return releaseStaleProfileWait.future;
}
return Future<void>.value();
},
preferredAudioTrack: audioTracks[0],
preferredSubtitleTrack: subtitleTracks[0],
);
addTearDown(mgr.dispose);
addTearDown(() {
if (!releaseStaleProfileWait.isCompleted) releaseStaleProfileWait.complete();
});
final staleApplication = mgr.applyTrackSelection();
await staleProfileWaitStarted.future;
await mgr.invalidatePendingSelection();
mgr.preferredAudioTrack = audioTracks[1];
mgr.preferredSubtitleTrack = subtitleTracks[1];
final replacementApplication = mgr.applyTrackSelection();
await _drainAsync();
expect(player.selectedAudio, isEmpty);
expect(player.selectedSubtitle, isEmpty);
expect(player.rates, isEmpty);
releaseStaleProfileWait.complete();
expect(await staleApplication, isFalse);
expect(await replacementApplication, isTrue);
expect(profileWaitCount, 2);
expect(player.selectedAudio.map((track) => track.id), ['audio-ja']);
expect(player.selectedSubtitle.map((track) => track.id), ['sub-es']);
expect(player.rates, [1.5]);
});
test('replacement open waits for an already-dispatched selection mutation to drain', () async {
await SettingsService.getInstance();
final audioSelectionStarted = Completer<void>();
final releaseAudioSelection = Completer<void>();
final player = _FakePlayer(tracks: availableTracks)
..onSelectAudioTrack = (_) async {
audioSelectionStarted.complete();
await releaseAudioSelection.future;
};
final mgr = _make(player: player, preferredAudioTrack: audioTracks[1], preferredSubtitleTrack: subtitleTracks[1]);
addTearDown(mgr.dispose);
addTearDown(() {
if (!releaseAudioSelection.isCompleted) releaseAudioSelection.complete();
});
final application = mgr.applyTrackSelection();
await audioSelectionStarted.future;
final dispatchedMutationDrain = mgr.invalidatePendingSelection();
var reloadCompleted = false;
final reload = () async {
await dispatchedMutationDrain;
await player.open(Media('https://example.com/replacement.mkv'));
reloadCompleted = true;
}();
await _drainAsync();
expect(player.openedMedia, isEmpty, reason: 'replacement media must not open across the native mutation');
expect(reloadCompleted, isFalse);
releaseAudioSelection.complete();
await reload;
expect(await application, isFalse);
expect(player.openedMedia, hasLength(1));
expect(reloadCompleted, isTrue);
expect(player.selectedSubtitle, isEmpty);
expect(player.rates, isEmpty);
});
test('disposing during an audio selection prevents later subtitle and rate writes', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.defaultPlaybackSpeed, 1.5);
final audioSelectionStarted = Completer<void>();
final releaseAudioSelection = Completer<void>();
final player = _FakePlayer(tracks: availableTracks)
..onSelectAudioTrack = (_) async {
audioSelectionStarted.complete();
await releaseAudioSelection.future;
};
final mgr = _make(player: player, preferredAudioTrack: audioTracks[1], preferredSubtitleTrack: subtitleTracks[1]);
final application = mgr.applyTrackSelection();
await audioSelectionStarted.future;
mgr.dispose();
releaseAudioSelection.complete();
expect(await application, isFalse);
expect(player.selectedAudio.map((track) => track.id), ['audio-ja']);
expect(player.selectedSubtitle, isEmpty);
expect(player.rates, isEmpty);
});
});
// ============================================================
// Track cycling early-return paths
// ============================================================
@@ -0,0 +1,354 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/models/trackers/device_code.dart';
import 'package:plezy/services/trackers/anilist/anilist_client.dart';
import 'package:plezy/services/trackers/mal/mal_auth_service.dart';
import 'package:plezy/services/trackers/mal/mal_client.dart';
import 'package:plezy/services/trackers/oauth_proxy_client.dart';
import 'package:plezy/services/trackers/simkl/simkl_auth_service.dart';
import 'package:plezy/services/trackers/simkl/simkl_client.dart';
import 'package:plezy/services/trackers/tracker_connect_runner.dart';
import 'package:plezy/services/trackers/tracker_exceptions.dart';
import 'package:plezy/services/trackers/tracker_constants.dart';
import 'package:plezy/services/trackers/tracker_session.dart';
import 'package:plezy/services/trakt/trakt_auth_service.dart';
import 'package:plezy/services/trakt/trakt_client.dart';
import 'package:plezy/utils/app_logger.dart';
import 'package:plezy/utils/log_redaction_manager.dart';
const _canaries = <String>[
'fint-access-Q7w9',
'fint-refresh-R8x0',
'fint-cookie-S9y1',
'fint-code-T0z2',
'fint-secret-U1a3',
'fint-email-V2b4@example.invalid',
'fint-identifier-W3c5',
'fint-nested-X4d6',
'fint-list-Y5e7',
'fint-unregistered-Z6f8',
];
String get _rejectedBody => json.encode({
'access_token': _canaries[0],
'refresh_token': _canaries[1],
'cookie': _canaries[2],
'code': _canaries[3],
'secret': _canaries[4],
'email': _canaries[5],
'identifier': _canaries[6],
'nested': {
'value': _canaries[7],
'items': [_canaries[8]],
},
'unknown_provider_field': _canaries[9],
});
TrackerSession _session() {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return TrackerSession(
accessToken: 'local-access',
refreshToken: 'local-refresh',
expiresAt: now + 86400,
createdAt: now,
username: 'local-user',
);
}
Future<bool> _runThroughConnect(Future<void> Function() operation, {required String label}) {
return runConnectPipeline<Object>(
logLabel: label,
authorize: () async {
await operation();
return Object();
},
enrich: (value) async => value,
save: (_) async {},
assign: (_) {},
);
}
String _retainedDiagnostics() {
return MemoryLogOutput.getLogs().map((entry) => '${entry.message}\n${entry.error ?? ''}').join('\n');
}
void _expectNoCanaries({required Iterable<String> expectedText}) {
final diagnostics = _retainedDiagnostics();
for (final canary in _canaries) {
expect(diagnostics, isNot(contains(canary)), reason: 'Retained remote response canary: $canary');
}
for (final text in expectedText) {
expect(diagnostics, contains(text));
}
}
const _deviceCode = DeviceCode(
deviceCode: 'local-device-code',
userCode: 'LOCAL-CODE',
verificationUrl: 'https://example.invalid/activate',
expiresIn: 600,
interval: 5,
);
void main() {
setUp(() {
MemoryLogOutput.clearLogs();
LogRedactionManager.clearTrackedValues();
});
tearDown(() {
MemoryLogOutput.clearLogs();
LogRedactionManager.clearTrackedValues();
});
group('tracker API diagnostics', () {
test('rejected Trakt, MAL, Simkl, and AniList bodies never reach the real connect catch', () async {
final cases = <({String service, Future<void> Function() run, void Function() dispose})>[];
final trakt = TraktClient(
_session(),
onSessionInvalidated: () {},
httpClient: MockClient((_) async => http.Response(_rejectedBody, 503)),
);
cases.add((service: 'trakt', run: () async => trakt.getUserSettings(), dispose: trakt.dispose));
final mal = MalClient(
_session(),
onSessionInvalidated: () {},
httpClient: MockClient((_) async => http.Response(_rejectedBody, 503)),
authService: MalAuthService(
proxy: OAuthProxyClient(httpClient: MockClient((_) async => fail('unused OAuth proxy'))),
httpClient: MockClient((_) async => fail('unused MAL auth client')),
),
);
cases.add((service: 'mal', run: () async => mal.getMyUser(), dispose: mal.dispose));
final simkl = SimklClient(
_session(),
onSessionInvalidated: () {},
httpClient: MockClient((_) async => http.Response(_rejectedBody, 503)),
);
cases.add((service: 'simkl', run: () async => simkl.getUserSettings(), dispose: simkl.dispose));
final anilist = AnilistClient(
_session(),
onSessionInvalidated: () {},
httpClient: MockClient((_) async => http.Response(_rejectedBody, 503)),
);
cases.add((service: 'anilist', run: () async => anilist.getViewerName(), dispose: anilist.dispose));
try {
for (final testCase in cases) {
MemoryLogOutput.clearLogs();
expect(await _runThroughConnect(testCase.run, label: testCase.service), isFalse);
_expectNoCanaries(
expectedText: ['${testCase.service} connect failed', 'TrackerApiException(${testCase.service}, HTTP 503)'],
);
}
} finally {
for (final testCase in cases) {
testCase.dispose();
}
}
});
test('AniList GraphQL errors use a fixed HTTP-200 category', () async {
final client = AnilistClient(
_session(),
onSessionInvalidated: () {},
httpClient: MockClient(
(_) async => http.Response(
json.encode({
'errors': [json.decode(_rejectedBody)],
}),
200,
),
),
);
addTearDown(client.dispose);
expect(await _runThroughConnect(() async => client.getViewerName(), label: 'anilist'), isFalse);
_expectNoCanaries(
expectedText: ['anilist connect failed', 'TrackerApiException(anilist, HTTP 200, graphqlErrors)'],
);
});
test('Trakt rate-limit metadata remains typed and body-free', () async {
final client = TraktClient(
_session(),
onSessionInvalidated: () {},
httpClient: MockClient((_) async => http.Response(_rejectedBody, 429, headers: {'retry-after': '23'})),
);
addTearDown(client.dispose);
TrackerRateLimitException? thrown;
try {
await client.getUserSettings();
} on TrackerRateLimitException catch (error) {
thrown = error;
}
expect(thrown, isNotNull);
expect(thrown!.service, TrackerService.trakt);
expect(thrown.retryAfterSeconds, 23);
MemoryLogOutput.clearLogs();
expect(await _runThroughConnect(() async => client.getUserSettings(), label: 'trakt'), isFalse);
_expectNoCanaries(expectedText: ['TrackerRateLimitException(trakt, retry-after: 23 s)']);
});
});
group('auth diagnostics', () {
for (final status in [400, 503]) {
test('MAL refresh HTTP $status preserves classification without retaining its body', () async {
final service = MalAuthService(
proxy: OAuthProxyClient(httpClient: MockClient((_) async => fail('unused OAuth proxy'))),
httpClient: MockClient((_) async => http.Response(_rejectedBody, status)),
);
addTearDown(service.dispose);
TrackerAuthException? thrown;
try {
await service.refresh(_session());
} on TrackerAuthException catch (error) {
thrown = error;
}
expect(thrown, isNotNull);
expect(thrown!.statusCode, status);
expect(thrown.isPermanent, status == 400);
_expectNoCanaries(expectedText: ['MAL: refresh failed (HTTP $status)']);
});
}
test('Trakt and Simkl code-creation errors retain only local operation and status', () async {
final trakt = TraktAuthService(httpClient: MockClient((_) async => http.Response(_rejectedBody, 502)));
final simkl = SimklAuthService(httpClient: MockClient((_) async => http.Response(_rejectedBody, 503)));
addTearDown(trakt.dispose);
addTearDown(simkl.dispose);
expect(await _runThroughConnect(() async => trakt.createDeviceCode(), label: 'trakt'), isFalse);
expect(await _runThroughConnect(() async => simkl.createDeviceCode(), label: 'simkl'), isFalse);
_expectNoCanaries(
expectedText: [
'DeviceCodeAuthFlowException: Trakt device code request failed: HTTP 502',
'DeviceCodeAuthFlowException: Simkl PIN request failed: HTTP 503',
],
);
});
test('Trakt unexpected poll status remains pending with fixed status diagnostics', () async {
final service = TraktAuthService(httpClient: MockClient((_) async => http.Response(_rejectedBody, 451)));
addTearDown(service.dispose);
expect(await service.probe(_deviceCode), isA<DevicePollPending>());
_expectNoCanaries(expectedText: ['Trakt device-code unexpected HTTP 451']);
});
test('Trakt device poll status mapping remains unchanged', () async {
for (final testCase in <({int status, String body, Matcher matcher})>[
(status: 200, body: json.encode({'access_token': 'local-token'}), matcher: isA<DevicePollSuccess>()),
(status: 400, body: _rejectedBody, matcher: isA<DevicePollPending>()),
(status: 404, body: _rejectedBody, matcher: isA<DevicePollExpired>()),
(status: 410, body: _rejectedBody, matcher: isA<DevicePollExpired>()),
(status: 409, body: _rejectedBody, matcher: isA<DevicePollDenied>()),
(status: 418, body: _rejectedBody, matcher: isA<DevicePollDenied>()),
(status: 429, body: _rejectedBody, matcher: isA<DevicePollSlowDown>()),
]) {
final service = TraktAuthService(
httpClient: MockClient((_) async => http.Response(testCase.body, testCase.status)),
);
try {
expect(await service.probe(_deviceCode), testCase.matcher);
} finally {
service.dispose();
}
}
_expectNoCanaries(expectedText: const []);
});
});
group('OAuth proxy diagnostics', () {
test('start and poll rejected bodies are status-only through the real connect catch', () async {
final startClient = OAuthProxyClient(httpClient: MockClient((_) async => http.Response(_rejectedBody, 502)));
final pollClient = OAuthProxyClient(httpClient: MockClient((_) async => http.Response(_rejectedBody, 503)));
addTearDown(startClient.dispose);
addTearDown(pollClient.dispose);
expect(await _runThroughConnect(() async => startClient.start('mal'), label: 'mal'), isFalse);
expect(await _runThroughConnect(() async => pollClient.poll('local-session'), label: 'mal'), isFalse);
_expectNoCanaries(
expectedText: [
'OAuthProxyException: OAuth proxy start failed: HTTP 502',
'OAuthProxyException: OAuth proxy poll failed: HTTP 503',
],
);
});
test('unknown provider error becomes a generic fixed category', () async {
final client = OAuthProxyClient(
httpClient: MockClient((_) async => http.Response(json.encode({'error': _canaries[9]}), 200)),
);
addTearDown(client.dispose);
expect(await _runThroughConnect(() async => client.poll('local-session'), label: 'anilist'), isFalse);
_expectNoCanaries(expectedText: ['OAuthProxyException: OAuth proxy failed: upstream authorization failed']);
});
test('recognized relay errors map to fixed local categories', () async {
for (final testCase in [
(code: 'missing_code', category: 'missing authorization code'),
(code: 'exchange_failed', category: 'token exchange failed'),
]) {
final client = OAuthProxyClient(
httpClient: MockClient((_) async => http.Response(json.encode({'error': testCase.code}), 200)),
);
try {
await expectLater(
client.poll('local-session'),
throwsA(
isA<OAuthProxyException>().having(
(error) => error.message,
'message',
'OAuth proxy failed: ${testCase.category}',
),
),
);
} finally {
client.dispose();
}
}
});
test('access denial still cancels and 204 still retries into fixed 410 expiry', () async {
final deniedClient = OAuthProxyClient(
httpClient: MockClient((_) async => http.Response(json.encode({'error': 'access_denied'}), 200)),
);
addTearDown(deniedClient.dispose);
expect(await deniedClient.poll('local-session'), isNull);
var requests = 0;
final expiryClient = OAuthProxyClient(
httpClient: MockClient((_) async {
requests++;
return requests == 1 ? http.Response('', 204) : http.Response(_rejectedBody, 410);
}),
);
addTearDown(expiryClient.dispose);
await expectLater(
expiryClient.poll('local-session'),
throwsA(
isA<OAuthProxyException>().having((error) => error.message, 'message', 'Session expired or already used'),
),
);
expect(requests, 2);
_expectNoCanaries(expectedText: const []);
});
});
}
+98
View File
@@ -0,0 +1,98 @@
import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/update_service.dart';
import '../test_helpers/prefs.dart';
void main() {
const lastCheckKey = 'update_last_check_time';
setUp(resetSharedPreferencesForTest);
PackageInfo.setMockInitialValues(
appName: 'Plezy',
packageName: 'com.plezy.test',
version: '1.0.0',
buildNumber: '1',
buildSignature: '',
);
test('malformed cooldown state fails open and removes the invalid value', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString(lastCheckKey, 'not-an-instant');
expect(await UpdateService.shouldCheckForUpdates(), isTrue);
expect(prefs.getString(lastCheckKey), isNull);
});
test('future cooldown state fails open and removes the invalid value', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString(lastCheckKey, DateTime.now().add(const Duration(days: 30)).toIso8601String());
expect(await UpdateService.shouldCheckForUpdates(), isTrue);
expect(prefs.getString(lastCheckKey), isNull);
});
test('recent valid cooldown state suppresses a duplicate check and remains stored', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final recent = DateTime.now().subtract(const Duration(minutes: 5)).toIso8601String();
await prefs.setString(lastCheckKey, recent);
expect(await UpdateService.shouldCheckForUpdates(), isFalse);
expect(prefs.getString(lastCheckKey), recent);
});
test('old valid cooldown state permits a new check', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final old = DateTime.now().subtract(const Duration(days: 2)).toIso8601String();
await prefs.setString(lastCheckKey, old);
expect(await UpdateService.shouldCheckForUpdates(), isTrue);
expect(prefs.getString(lastCheckKey), old);
});
final failedResponses = <String, Future<http.Response> Function()>{
'timeout': () async => throw TimeoutException('request timed out'),
'non-200 response': () async => http.Response('unavailable', 503),
'parse failure': () async => http.Response('not-json', 200, headers: {'content-type': 'application/json'}),
};
for (final failure in failedResponses.entries) {
test('startup ${failure.key} records cooldown before request and manual check bypasses it', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final cooldownAtRequest = <String?>[];
var requestCount = 0;
final client = MediaServerHttpClient(
client: MockClient((_) async {
requestCount++;
cooldownAtRequest.add(prefs.getString(lastCheckKey));
return failure.value();
}),
);
addTearDown(client.close);
expect(await UpdateService.debugPerformUpdateCheck(respectCooldown: true, client: client), isNull);
expect(requestCount, 1);
expect(cooldownAtRequest.single, isNotNull);
final recordedCooldown = prefs.getString(lastCheckKey);
expect(recordedCooldown, cooldownAtRequest.single);
expect(DateTime.now().difference(DateTime.parse(recordedCooldown!)), lessThan(const Duration(minutes: 1)));
expect(await UpdateService.debugPerformUpdateCheck(respectCooldown: true, client: client), isNull);
expect(requestCount, 1, reason: 'a simulated next launch must honor the failed attempt cooldown');
expect(await UpdateService.debugPerformUpdateCheck(respectCooldown: false, client: client), isNull);
expect(requestCount, 2, reason: 'an explicit manual check must bypass a recent startup cooldown');
expect(
prefs.getString(lastCheckKey),
recordedCooldown,
reason: 'manual checks must not rewrite startup cooldown',
);
});
}
}
@@ -0,0 +1,451 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/mpv/player/platform/player_android.dart';
import 'package:plezy/mpv/player/player_native.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/video_volume_controller.dart';
import '../test_helpers/mock_player_channels.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late SettingsService settings;
setUp(() async {
resetSharedPreferencesForTest(initialAsync: {SettingsService.volume.key: 50.0, SettingsService.maxVolume.key: 100});
SettingsService.resetForTesting();
settings = await SettingsService.getInstance();
});
test('rapid repeats and wheel deltas accumulate and coalesce against intent', () async {
final player = _ControlledVolumePlayer(50);
final persisted = <double>[];
final desired = <double>[];
final controller = VideoVolumeController(
player: player,
settings: settings,
initialVolume: 50,
persistVolume: (volume) async => persisted.add(volume),
);
addTearDown(controller.dispose);
controller.addListener(() => desired.add(controller.value));
controller.adjust(5);
controller.adjust(5);
controller.adjust(5);
expect(controller.value, 65);
expect(desired, [55, 60, 65]);
expect(player.requestedVolumes, [55]);
expect(player.maxConcurrentWrites, 1);
player.succeedNext();
await _flush();
expect(player.requestedVolumes, [55, 65]);
expect(persisted, isEmpty);
player.succeedNext();
await controller.idle;
expect(player.volume, 65);
expect(persisted, [65]);
expect(player.maxConcurrentWrites, 1);
// Three wheel ticks using the production -dy/20 conversion are another
// +15 burst even though native publication is held back.
controller.adjust(-(-100) / 20);
controller.adjust(-(-100) / 20);
controller.adjust(-(-100) / 20);
expect(controller.value, 80);
expect(player.requestedVolumes.last, 70);
player.succeedNext();
await _flush();
expect(player.requestedVolumes.last, 80);
player.succeedNext();
await controller.idle;
expect(persisted.last, 80);
});
test('alternating deltas preserve order and clamp to configured boundaries', () async {
final player = _ControlledVolumePlayer(50);
final persisted = <double>[];
final controller = VideoVolumeController(
player: player,
settings: settings,
initialVolume: 50,
persistVolume: (volume) async => persisted.add(volume),
);
addTearDown(controller.dispose);
controller.adjust(5);
controller.adjust(-10);
controller.adjust(5);
expect(controller.value, 50);
expect(player.requestedVolumes, [55]);
player.succeedNext();
await _flush();
expect(player.requestedVolumes, [55, 50]);
player.succeedNext();
await controller.idle;
expect(persisted, [50]);
controller.adjust(-500);
controller.adjust(-5);
expect(controller.value, 0);
player.succeedNext();
await controller.idle;
expect(player.requestedVolumes.last, 0);
expect(persisted.last, 0);
controller.adjust(500);
controller.adjust(5);
expect(controller.value, 100);
player.succeedNext();
await controller.idle;
expect(player.requestedVolumes.last, 100);
expect(persisted.last, 100);
});
test('preview bursts commit only the final absolute value', () async {
final player = _ControlledVolumePlayer(50);
final persisted = <double>[];
final controller = VideoVolumeController(
player: player,
settings: settings,
initialVolume: 50,
persistVolume: (volume) async => persisted.add(volume),
);
addTearDown(controller.dispose);
controller.preview(60);
controller.preview(65);
controller.preview(70);
controller.commit(70);
expect(controller.value, 70);
expect(player.requestedVolumes, [60]);
player.succeedNext();
await _flush();
expect(player.requestedVolumes, [60, 70]);
expect(persisted, isEmpty);
player.succeedNext();
await controller.idle;
expect(persisted, [70]);
});
test('rapid mute transitions preserve the exact preferred non-zero volume', () async {
await settings.write(SettingsService.volume, 37.0);
final player = _ControlledVolumePlayer(37);
final persisted = <double>[];
final controller = VideoVolumeController(
player: player,
settings: settings,
initialVolume: 37,
persistVolume: (volume) async => persisted.add(volume),
);
addTearDown(controller.dispose);
controller.toggleMute();
controller.toggleMute();
expect(controller.value, 37);
expect(player.requestedVolumes, [0]);
player.succeedNext();
await _flush();
expect(player.requestedVolumes, [0, 37]);
expect(persisted, isEmpty);
player.succeedNext();
await controller.idle;
expect(persisted, [37]);
controller.adjust(5);
controller.toggleMute();
expect(controller.value, 0);
player.succeedNext();
await _flush();
expect(player.requestedVolumes.last, 0);
player.succeedNext();
await controller.idle;
expect(persisted.last, 42);
});
test('obsolete apply failure drains newer intent and current failure rolls back', () async {
final player = _ControlledVolumePlayer(50);
final persisted = <double>[];
final controller = VideoVolumeController(
player: player,
settings: settings,
initialVolume: 50,
persistVolume: (volume) async => persisted.add(volume),
);
addTearDown(controller.dispose);
controller.adjust(5);
controller.adjust(10);
player.failNext(StateError('first apply failed'));
await _flush();
expect(controller.value, 65);
expect(player.requestedVolumes, [55, 65]);
player.succeedNext();
await controller.idle;
expect(persisted, [65]);
controller.adjust(5);
expect(controller.value, 70);
player.failNext(StateError('latest apply failed'));
await controller.idle;
expect(controller.value, 65);
expect(player.volume, 65);
expect(persisted, [65]);
});
test('persistence failure is contained and a later command converges', () async {
final player = _ControlledVolumePlayer(50);
final attempts = <double>[];
var failNextPersistence = true;
final controller = VideoVolumeController(
player: player,
settings: settings,
initialVolume: 50,
persistVolume: (volume) async {
attempts.add(volume);
if (failNextPersistence) {
failNextPersistence = false;
throw StateError('persistence failed');
}
},
);
addTearDown(controller.dispose);
controller.adjust(5);
player.succeedNext();
await controller.idle;
expect(controller.value, 55);
expect(attempts, [55]);
controller.adjust(5);
player.succeedNext();
await controller.idle;
expect(controller.value, 60);
expect(attempts, [55, 60]);
expect(player.maxConcurrentWrites, 1);
});
test('idle observations resynchronize but in-flight observations cannot erase intent', () async {
final player = _ControlledVolumePlayer(50);
final controller = VideoVolumeController(player: player, settings: settings, initialVolume: 50);
addTearDown(controller.dispose);
player.publish(40);
await _flush();
expect(controller.value, 40);
controller.adjust(5);
player.publish(10);
await _flush();
expect(controller.value, 45);
player.failNext(StateError('apply failed'));
await controller.idle;
expect(controller.value, 40);
});
test('dispose invalidates pending native and persistence continuations', () async {
final player = _ControlledVolumePlayer(50);
final persisted = <double>[];
final controller = VideoVolumeController(
player: player,
settings: settings,
initialVolume: 50,
persistVolume: (volume) async => persisted.add(volume),
);
controller.adjust(5);
controller.adjust(5);
expect(player.requestedVolumes, [55]);
controller.dispose();
controller.adjust(50);
controller.toggleMute();
player.succeedNext();
await _flush();
expect(player.requestedVolumes, [55]);
expect(persisted, isEmpty);
});
for (final adapter in <({String name, String methodChannel, String eventChannel, Player Function() create})>[
(
name: 'PlayerNative',
methodChannel: 'com.plezy/mpv_player',
eventChannel: 'com.plezy/mpv_player/events',
create: PlayerNative.new,
),
(
name: 'PlayerAndroid',
methodChannel: 'com.plezy/exo_player',
eventChannel: 'com.plezy/exo_player/events',
create: PlayerAndroid.new,
),
]) {
test('${adapter.name} receives one ordered native volume write at a time', () async {
final firstWriteStarted = Completer<void>();
final releaseFirstWrite = Completer<void>();
final nativeVolumes = <double>[];
var activeWrites = 0;
var maxActiveWrites = 0;
await withMockPlayerChannels(
methodChannelName: adapter.methodChannel,
eventChannelName: adapter.eventChannel,
methodHandler: (MethodCall call) async {
if (call.method == 'initialize') return true;
double? volume;
if (call.method == 'setVolume') {
volume = ((call.arguments as Map)['volume'] as num).toDouble();
} else if (call.method == 'setProperty') {
final arguments = call.arguments as Map;
if (arguments['name'] == 'volume') {
volume = double.parse(arguments['value'] as String);
}
}
if (volume == null) return null;
nativeVolumes.add(volume);
activeWrites++;
if (activeWrites > maxActiveWrites) maxActiveWrites = activeWrites;
if (!firstWriteStarted.isCompleted) {
firstWriteStarted.complete();
await releaseFirstWrite.future;
}
activeWrites--;
return null;
},
testBody: () async {
final player = adapter.create();
final persisted = <double>[];
final controller = VideoVolumeController(
player: player,
settings: settings,
initialVolume: 50,
persistVolume: (volume) async => persisted.add(volume),
);
try {
controller.adjust(5);
controller.adjust(5);
controller.adjust(5);
await firstWriteStarted.future;
expect(nativeVolumes, [55]);
releaseFirstWrite.complete();
await controller.idle;
expect(nativeVolumes, [55, 65]);
expect(persisted, [65]);
expect(maxActiveWrites, 1);
controller.dispose();
controller.adjust(10);
controller.toggleMute();
await _flush();
expect(nativeVolumes, [55, 65]);
expect(persisted, [65]);
} finally {
if (!releaseFirstWrite.isCompleted) releaseFirstWrite.complete();
controller.dispose();
await player.dispose();
}
},
);
});
}
}
Future<void> _flush() => Future<void>.delayed(Duration.zero);
final class _ControlledVolumePlayer implements Player {
_ControlledVolumePlayer(this.volume);
double volume;
final requestedVolumes = <double>[];
final _requests = <_VolumeRequest>[];
final _volumeStream = StreamController<double>.broadcast();
int _activeWrites = 0;
int maxConcurrentWrites = 0;
@override
PlayerState get state => PlayerState(volume: volume);
@override
PlayerStreams get streams => PlayerStreams(
playing: const Stream<bool>.empty(),
completed: const Stream<bool>.empty(),
buffering: const Stream<bool>.empty(),
position: const Stream<Duration>.empty(),
duration: const Stream<Duration>.empty(),
seekable: const Stream<bool>.empty(),
buffer: const Stream<Duration>.empty(),
volume: _volumeStream.stream,
rate: const Stream<double>.empty(),
tracks: const Stream<Tracks>.empty(),
track: const Stream<TrackSelection>.empty(),
log: const Stream<PlayerLog>.empty(),
error: const Stream<PlayerError>.empty(),
audioDevice: const Stream<AudioDevice>.empty(),
audioDevices: const Stream<List<AudioDevice>>.empty(),
bufferRanges: const Stream<List<BufferRange>>.empty(),
playbackRestart: const Stream<void>.empty(),
backendSwitched: const Stream<void>.empty(),
);
@override
Future<void> setVolume(double requested) async {
requestedVolumes.add(requested);
_activeWrites++;
if (_activeWrites > maxConcurrentWrites) maxConcurrentWrites = _activeWrites;
final request = _VolumeRequest(requested);
_requests.add(request);
try {
await request.completer.future;
volume = requested;
_volumeStream.add(requested);
} finally {
_activeWrites--;
}
}
void succeedNext() {
final request = _requests.firstWhere((request) => !request.completer.isCompleted);
request.completer.complete();
}
void failNext(Object error) {
final request = _requests.firstWhere((request) => !request.completer.isCompleted);
request.completer.completeError(error);
}
void publish(double observed) {
volume = observed;
_volumeStream.add(observed);
}
@override
Future<void> dispose({bool preserveDisplayMode = false}) async {
await _volumeStream.close();
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
final class _VolumeRequest {
_VolumeRequest(this.volume);
final double volume;
final Completer<void> completer = Completer<void>();
}