fix(runtime): harden application service boundaries
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 []);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
Reference in New Issue
Block a user