Files
plezy/test/widgets/media_context_menu_test.dart
T

348 lines
12 KiB
Dart

import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/library_query.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/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/metadata_edit/metadata_edit_adapters.dart';
import 'package:plezy/models/plex/plex_home_user.dart';
import 'package:plezy/profiles/profile.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/profiles/plex_home_service.dart';
import 'package:plezy/profiles/profile_connection_registry.dart';
import 'package:plezy/profiles/profile_registry.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/media_context_menu.dart';
import 'package:provider/provider.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('isAdminActionAllowedForMediaItem', () {
test('blocks non-admin Plex Home users on Plex items', () {
final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: false));
expect(
isAdminActionAllowedForMediaItem(isOwnerOrAdmin: true, itemBackend: MediaBackend.plex, activeProfile: profile),
isFalse,
);
});
test('does not apply Plex Home role to Jellyfin items', () {
final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: false));
expect(
isAdminActionAllowedForMediaItem(
isOwnerOrAdmin: true,
itemBackend: MediaBackend.jellyfin,
activeProfile: profile,
),
isTrue,
);
});
test('allows Plex admin Home users on Plex items', () {
final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: true));
expect(
isAdminActionAllowedForMediaItem(isOwnerOrAdmin: true, itemBackend: MediaBackend.plex, activeProfile: profile),
isTrue,
);
});
});
group('supportsMetadataEdit', () {
test('allows Jellyfin video metadata edit through capability gate', () {
final client = JellyfinClient.forTesting(
connection: _jellyfinConnection(),
httpClient: MockClient((_) async => http.Response('', 204)),
);
addTearDown(client.close);
expect(supportsMetadataEdit(client, MediaKind.movie), isTrue);
expect(supportsMetadataEdit(client, MediaKind.show), isTrue);
expect(supportsMetadataEdit(client, MediaKind.track), isFalse);
});
});
group('MediaContextMenu actions', () {
testWidgets('audio playlist play and shuffle actions use music playback', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
final tracks = [
MediaItem(
id: 'track-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Track One',
serverId: 'srv-1',
),
MediaItem(
id: 'track-2',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Track Two',
serverId: 'srv-1',
),
];
final client = _AudioPlaylistClient(tracks);
final music = _RecordingMusicPlaybackService();
final db = AppDatabase.forTesting(NativeDatabase.memory());
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final connections = ConnectionRegistry(db);
final profileConnections = ProfileConnectionRegistry(db);
final plexHome = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfileProvider = ActiveProfileProvider(
registry: ProfileRegistry(db),
plexHome: plexHome,
connections: connections,
);
addTearDown(() async {
activeProfileProvider.dispose();
await plexHome.dispose();
music.dispose();
multiServerProvider.dispose();
manager.dispose();
await db.close();
});
final menuKey = GlobalKey<MediaContextMenuState>();
const playlist = MediaPlaylist(
id: 'playlist-1',
backend: MediaBackend.jellyfin,
title: 'Road Trip',
playlistType: 'audio',
serverId: 'srv-1',
);
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
ChangeNotifierProvider<MusicPlaybackService>.value(value: music),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: Center(
child: MediaContextMenu(
key: menuKey,
item: playlist,
child: const SizedBox(width: 120, height: 80, child: Text('audio target')),
),
),
),
),
),
),
);
menuKey.currentState!.showContextMenu(tester.element(find.text('audio target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.common.play));
await tester.pumpAndSettle();
expect(music.playedTracks, tracks);
expect(music.playedContext?.id, playlist.id);
expect(music.playedContext?.title, playlist.title);
expect(music.playedContext?.kind, MusicPlayContextKind.playlist);
expect(music.shuffle, isFalse);
menuKey.currentState!.showContextMenu(tester.element(find.text('audio target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.mediaMenu.shufflePlay));
await tester.pumpAndSettle();
expect(music.callCount, 2);
expect(music.playedTracks, tracks);
expect(music.shuffle, isTrue);
expect(tester.takeException(), isNull);
});
testWidgets('file info client resolution failure shows an error without popping another route', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
final db = AppDatabase.forTesting(NativeDatabase.memory());
final manager = MultiServerManager();
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final connections = ConnectionRegistry(db);
final profileConnections = ProfileConnectionRegistry(db);
final plexHome = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfileProvider = ActiveProfileProvider(
registry: ProfileRegistry(db),
plexHome: plexHome,
connections: connections,
);
addTearDown(() async {
activeProfileProvider.dispose();
await plexHome.dispose();
multiServerProvider.dispose();
manager.dispose();
await db.close();
});
final menuKey = GlobalKey<MediaContextMenuState>();
final item = MediaItem(
id: 'movie-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
title: 'Movie',
serverId: 'missing-server',
);
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: Center(
child: MediaContextMenu(
key: menuKey,
item: item,
child: const SizedBox(width: 120, height: 80, child: Text('target')),
),
),
),
),
),
),
);
menuKey.currentState!.showContextMenu(tester.element(find.text('target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.mediaMenu.fileInfo));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.byType(SnackBar), findsOneWidget);
expect(find.text('target'), findsOneWidget);
});
});
}
class _AudioPlaylistClient implements MediaServerClient {
final List<MediaItem> tracks;
_AudioPlaylistClient(this.tracks);
@override
ServerId get serverId => ServerId('srv-1');
@override
String? get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
@override
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async {
final offset = start ?? 0;
final limit = size ?? tracks.length;
return LibraryPage(items: tracks.skip(offset).take(limit).toList(), totalCount: tracks.length, offset: offset);
}
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _RecordingMusicPlaybackService extends StubMusicPlaybackService {
List<MediaItem>? playedTracks;
MusicPlayContext? playedContext;
bool? shuffle;
int callCount = 0;
@override
bool get isAvailable => true;
@override
Future<void> playFromList({
required List<MediaItem> tracks,
MediaItem? startTrack,
required MusicPlayContext playContext,
bool shuffle = false,
}) async {
callCount++;
playedTracks = tracks;
playedContext = playContext;
this.shuffle = shuffle;
}
}
PlexHomeUser _homeUser({required bool admin}) {
return PlexHomeUser(
id: 0,
uuid: 'home-user',
title: 'Home User',
username: null,
email: null,
friendlyName: null,
thumb: 'https://plex.tv/users/home-user/avatar',
hasPassword: false,
restricted: false,
updatedAt: null,
admin: admin,
guest: false,
protected: false,
);
}
JellyfinConnection _jellyfinConnection() {
return JellyfinConnection(
id: 'srv-1/user-1',
baseUrl: 'https://jf.example.com',
serverName: 'Home',
serverMachineId: 'srv-1',
userId: 'user-1',
userName: 'edde',
accessToken: 'tok',
deviceId: 'dev',
isAdministrator: true,
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
}